nutria-plugin 0.2.0__tar.gz → 0.2.2__tar.gz

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.
Files changed (37) hide show
  1. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/CHANGELOG.md +13 -0
  2. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/PKG-INFO +20 -6
  3. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/README.md +18 -4
  4. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/index.md +3 -2
  5. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/manifest.md +30 -2
  6. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/pyproject.toml +1 -1
  7. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/__init__.py +17 -1
  8. nutria_plugin-0.2.2/src/nutria_plugin/capabilities.py +350 -0
  9. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/manifest.py +23 -2
  10. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/packaging.py +1 -1
  11. nutria_plugin-0.2.2/tests/test_capability_contracts.py +97 -0
  12. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_manifest.py +75 -1
  13. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/uv.lock +1 -1
  14. nutria_plugin-0.2.0/src/nutria_plugin/capabilities.py +0 -142
  15. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/.github/workflows/publish.yml +0 -0
  16. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/.gitignore +0 -0
  17. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/admin-extensions.md +0 -0
  18. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/admin-flows.md +0 -0
  19. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/cli.md +0 -0
  20. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/connection-types.md +0 -0
  21. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/python-api.md +0 -0
  22. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/quickstart.md +0 -0
  23. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/reviewable-actions.md +0 -0
  24. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/security.md +0 -0
  25. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/skill-format.md +0 -0
  26. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/examples/my-first-plugin/README.md +0 -0
  27. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/examples/my-first-plugin/hooks/hooks.json +0 -0
  28. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/examples/my-first-plugin/plugin.json +0 -0
  29. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/examples/my-first-plugin/settings.schema.json +0 -0
  30. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/bundle.py +0 -0
  31. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/cli.py +0 -0
  32. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/signing.py +0 -0
  33. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_bundle.py +0 -0
  34. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_cli.py +0 -0
  35. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_packaging.py +0 -0
  36. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_reviewable_actions.py +0 -0
  37. {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_signing.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - Add backward-compatible schema-2.1 capability exposure and safe non-callability metadata.
6
+ - Add prepared-action, idempotency, and completion receipt contracts.
7
+ - Require model-selectable external writes to declare all execution safety contracts.
8
+
9
+ ## 0.2.1
10
+
11
+ - Add manifest schema `2.1` with typed world providers, custom resource types,
12
+ stable identity fields, safe projections, and provider capability references.
13
+ - Keep schema `2.0` compatible and export the new provider descriptor models.
14
+ - Scaffold new plugins with schema `2.1` while preserving the 0.3.0 roadmap.
15
+
3
16
  ## 0.2.0
4
17
 
5
18
  - Make manifest schema `2.0` the single production schema.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: nutria-plugin
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: SDK for building, validating, signing, and packaging Nutria plugins
5
5
  Project-URL: Homepage, https://github.com/AlRos14/nutria-plugin-sdk
6
6
  Project-URL: Repository, https://github.com/AlRos14/nutria-plugin-sdk
@@ -21,8 +21,9 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  SDK for building, validating, signing, and packaging Nutria plugins.
23
23
 
24
- Release `0.2.0` introduces the typed capability graph and manifest schema
25
- `2.0`. ChatBotNutralia owns reviewable drafts, revisions, and approval; a
24
+ Release `0.2.2` adds capability exposure, prepared-action, idempotency, and
25
+ completion contracts to manifest schema `2.1` while continuing to accept existing
26
+ schema `2.0` and `2.1` manifests. ChatBotNutralia owns reviewable drafts, revisions, and approval; a
26
27
  plugin only reads channel state and delivers the exact approved snapshot.
27
28
 
28
29
  This release also supports **declarative admin extensions**, allowing plugins
@@ -62,7 +63,7 @@ my-workspace-plugin/
62
63
 
63
64
  ```json
64
65
  {
65
- "schema_version": "2.0",
66
+ "schema_version": "2.1",
66
67
  "id": "my-workspace-plugin",
67
68
  "name": "My Workspace Plugin",
68
69
  "version": "0.1.0",
@@ -81,7 +82,20 @@ my-workspace-plugin/
81
82
  "tool": "search_workspace",
82
83
  "connection_id": "workspace"
83
84
  }
84
- ]
85
+ ],
86
+ "world_providers": [{
87
+ "id": "workspace",
88
+ "title": "Workspace",
89
+ "description": "Authoritative workspace resources.",
90
+ "connection_id": "workspace",
91
+ "resource_types": [{
92
+ "id": "workspace.item",
93
+ "title": "Workspace item",
94
+ "description": "One stable workspace item.",
95
+ "identity_fields": ["id"],
96
+ "search_capability": "workspace.search"
97
+ }]
98
+ }]
85
99
  }
86
100
  ```
87
101
 
@@ -2,8 +2,9 @@
2
2
 
3
3
  SDK for building, validating, signing, and packaging Nutria plugins.
4
4
 
5
- Release `0.2.0` introduces the typed capability graph and manifest schema
6
- `2.0`. ChatBotNutralia owns reviewable drafts, revisions, and approval; a
5
+ Release `0.2.2` adds capability exposure, prepared-action, idempotency, and
6
+ completion contracts to manifest schema `2.1` while continuing to accept existing
7
+ schema `2.0` and `2.1` manifests. ChatBotNutralia owns reviewable drafts, revisions, and approval; a
7
8
  plugin only reads channel state and delivers the exact approved snapshot.
8
9
 
9
10
  This release also supports **declarative admin extensions**, allowing plugins
@@ -43,7 +44,7 @@ my-workspace-plugin/
43
44
 
44
45
  ```json
45
46
  {
46
- "schema_version": "2.0",
47
+ "schema_version": "2.1",
47
48
  "id": "my-workspace-plugin",
48
49
  "name": "My Workspace Plugin",
49
50
  "version": "0.1.0",
@@ -62,7 +63,20 @@ my-workspace-plugin/
62
63
  "tool": "search_workspace",
63
64
  "connection_id": "workspace"
64
65
  }
65
- ]
66
+ ],
67
+ "world_providers": [{
68
+ "id": "workspace",
69
+ "title": "Workspace",
70
+ "description": "Authoritative workspace resources.",
71
+ "connection_id": "workspace",
72
+ "resource_types": [{
73
+ "id": "workspace.item",
74
+ "title": "Workspace item",
75
+ "description": "One stable workspace item.",
76
+ "identity_fields": ["id"],
77
+ "search_capability": "workspace.search"
78
+ }]
79
+ }]
66
80
  }
67
81
  ```
68
82
 
@@ -31,6 +31,7 @@ Plugins define:
31
31
  - **Settings schema** — admin-configurable options shown in the Nutria UI
32
32
  - **Admin extensions** — safe, declarative operator views rendered by the Nutria host
33
33
  - **Admin flows** — safe, declarative operator workflows such as external login/pairing
34
+ - **World providers** — resource types and capability paths injected into the host graph
34
35
 
35
36
  ## Minimal plugin structure
36
37
 
@@ -53,7 +54,7 @@ my-plugin/
53
54
 
54
55
  ## Version
55
56
 
56
- This documentation describes `nutria-plugin` **v0.2.0** and manifest schema
57
- **2.0**.
57
+ This documentation describes `nutria-plugin` **v0.2.1** and manifest schema
58
+ **2.1**. Schema 2.0 remains accepted for plugins that do not declare world providers.
58
59
 
59
60
  API and file format compatibility are not yet guaranteed between pre-release releases.
@@ -8,7 +8,7 @@ directory as `plugin.json`.
8
8
 
9
9
  ```json
10
10
  {
11
- "schema_version": "2.0",
11
+ "schema_version": "2.1",
12
12
  "id": "my-plugin",
13
13
  "name": "My Plugin",
14
14
  "version": "0.1.0",
@@ -42,6 +42,19 @@ directory as `plugin.json`.
42
42
  "connection_id": "workspace"
43
43
  }
44
44
  ],
45
+ "world_providers": [{
46
+ "id": "workspace",
47
+ "title": "Workspace",
48
+ "description": "Authoritative workspace resources.",
49
+ "connection_id": "workspace",
50
+ "resource_types": [{
51
+ "id": "workspace.item",
52
+ "title": "Workspace item",
53
+ "description": "One stable workspace item.",
54
+ "identity_fields": ["id"],
55
+ "search_capability": "workspace.search"
56
+ }]
57
+ }],
45
58
  "tags": ["crm", "sales"],
46
59
  "admin_extensions": [
47
60
  {
@@ -73,7 +86,8 @@ directory as `plugin.json`.
73
86
 
74
87
  ### `schema_version` *(string, required)*
75
88
 
76
- The manifest schema is `"2.0"`. Every plugin must declare this version.
89
+ The current manifest schema is `"2.1"`; schema `"2.0"` remains accepted for
90
+ backward compatibility. Schema 2.1 adds provider-backed world graph contracts.
77
91
  Capabilities and reviewable actions are typed by the SDK; schema `1.x` is not
78
92
  accepted by the production host.
79
93
 
@@ -285,6 +299,20 @@ See [reviewable-actions.md](reviewable-actions.md) for the complete schema.
285
299
 
286
300
  ---
287
301
 
302
+ ### `world_providers` *(array of objects, optional; schema 2.1)*
303
+
304
+ Each provider declares a stable `id`, title, description, optional
305
+ `connection_id` and `health_capability`, plus one or more resource types. A
306
+ resource type has a stable built-in or plugin-namespaced custom `id`, at least
307
+ one `identity_fields` entry, optional `search_capability` and
308
+ `inspect_capability`, bounded `ttl_seconds`, and named safe `projections`.
309
+
310
+ Referenced capabilities must exist in the same manifest. Provider declarations
311
+ describe navigation only: the host still enforces audience, persona assignment,
312
+ connection state, and effect authorization before executing a tool.
313
+
314
+ ---
315
+
288
316
  ### `tags` *(array of strings, optional)*
289
317
 
290
318
  Discovery tags shown in the plugin marketplace.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nutria-plugin"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "SDK for building, validating, signing, and packaging Nutria plugins"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -15,16 +15,24 @@ Signing:
15
15
  generate_keypair, sign_manifest, verify_manifest, SignatureStatus
16
16
  """
17
17
 
18
- __version__ = "0.2.0"
18
+ __version__ = "0.2.2"
19
19
 
20
20
  from .capabilities import (
21
21
  CapabilityDescriptor,
22
22
  CapabilityEffect,
23
+ CapabilityExposure,
23
24
  CapabilityInputBinding,
24
25
  CapabilityOutputBinding,
25
26
  CapabilityRequirement,
27
+ CompletionDescriptor,
28
+ IdempotencyDescriptor,
29
+ NonCallableReason,
30
+ PreparedActionDescriptor,
26
31
  ResourceBinding,
27
32
  ResourceType,
33
+ ResourceTypeDescriptor,
34
+ WorldProjectionDescriptor,
35
+ WorldProviderDescriptor,
28
36
  )
29
37
 
30
38
 
@@ -69,11 +77,19 @@ __all__ = [
69
77
  "PreparationToolContract",
70
78
  "CapabilityDescriptor",
71
79
  "CapabilityEffect",
80
+ "CapabilityExposure",
72
81
  "CapabilityInputBinding",
73
82
  "CapabilityOutputBinding",
74
83
  "CapabilityRequirement",
84
+ "CompletionDescriptor",
85
+ "IdempotencyDescriptor",
86
+ "NonCallableReason",
87
+ "PreparedActionDescriptor",
75
88
  "ResourceBinding",
76
89
  "ResourceType",
90
+ "ResourceTypeDescriptor",
91
+ "WorldProjectionDescriptor",
92
+ "WorldProviderDescriptor",
77
93
  # Bundle
78
94
  "PluginBundleError",
79
95
  "load_plugin_bundle",
@@ -0,0 +1,350 @@
1
+ """Typed resource and capability contracts for Nutria plugin manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field, field_validator, model_validator
9
+
10
+
11
+ _RESOURCE_TYPE_PATTERN = r"^[a-z][a-z0-9_.-]{0,127}$"
12
+
13
+
14
+ class CapabilityEffect(str, Enum):
15
+ READ = "read"
16
+ PREPARE = "prepare"
17
+ WRITE = "write"
18
+ EXTERNAL_WRITE = "external_write"
19
+
20
+
21
+ class CapabilityExposure(str, Enum):
22
+ """Who may invoke a graph-visible capability."""
23
+
24
+ MODEL = "model"
25
+ HOST = "host"
26
+ ADMIN = "admin"
27
+ DEPRECATED = "deprecated"
28
+
29
+
30
+ class NonCallableReason(BaseModel):
31
+ """Safe explanation for a graph-visible capability that the model cannot load."""
32
+
33
+ code: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
34
+ safe_summary: str = Field(..., min_length=1, max_length=500)
35
+
36
+ model_config = {"extra": "forbid"}
37
+
38
+
39
+ class PreparedActionDescriptor(BaseModel):
40
+ """Exact-preview contract used by the host prepared-action boundary."""
41
+
42
+ preview_argument: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
43
+ preview_value: Any
44
+ execute_value: Any
45
+ adapter: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
46
+ ttl_seconds: int = Field(..., ge=60, le=86_400)
47
+ merge_previews: bool
48
+ guard_mode: str = Field(..., pattern=r"^(always|pending_only)$")
49
+ argument_default: Any
50
+
51
+ model_config = {"extra": "forbid"}
52
+
53
+
54
+ class IdempotencyDescriptor(BaseModel):
55
+ """Execution idempotency contract for a capability."""
56
+
57
+ argument_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
58
+ required_for_execution: bool
59
+
60
+ model_config = {"extra": "forbid"}
61
+
62
+
63
+ class CompletionDescriptor(BaseModel):
64
+ """Authoritative receipt kinds required before a capability is complete."""
65
+
66
+ receipts: list[str] = Field(..., min_length=1)
67
+
68
+ model_config = {"extra": "forbid"}
69
+
70
+ @field_validator("receipts")
71
+ @classmethod
72
+ def _validate_receipts(cls, value: list[str]) -> list[str]:
73
+ cleaned = list(dict.fromkeys(item.strip() for item in value if item.strip()))
74
+ if not cleaned:
75
+ raise ValueError("completion receipts must not be empty")
76
+ for receipt in cleaned:
77
+ import re
78
+
79
+ if not re.fullmatch(r"^[a-z][a-z0-9_.-]{0,127}$", receipt):
80
+ raise ValueError("completion receipts must be stable lowercase identifiers")
81
+ return cleaned
82
+
83
+
84
+ class ResourceType(str, Enum):
85
+ TENANT = "tenant"
86
+ CLIENT = "client"
87
+ BRAND = "brand"
88
+ AGENT = "agent"
89
+ SERVICE_PRINCIPAL = "service_principal"
90
+ HUMAN_ACTOR = "human_actor"
91
+ CUSTOMER_SESSION = "customer_session"
92
+ CONNECTOR = "connector"
93
+ TEAM = "team"
94
+ AUTHORITY_GRANT = "authority_grant"
95
+ CONVERSATION = "conversation"
96
+ CHANNEL_CONVERSATION = "channel_conversation"
97
+ MESSAGE = "message"
98
+ CHANNEL_EVENT = "channel_event"
99
+ ATTACHMENT = "attachment"
100
+ CASE = "case"
101
+ ORDER = "order"
102
+ PREPARED_ACTION = "prepared_action"
103
+ OPERATION = "operation"
104
+ RESPONSE = "response"
105
+ ARTIFACT = "artifact"
106
+ CAPABILITY = "capability"
107
+ CUSTOMER = "customer"
108
+ EMAIL_THREAD = "email_thread"
109
+ EMAIL_MESSAGE = "email_message"
110
+ WHATSAPP_CHAT = "whatsapp_chat"
111
+ WHATSAPP_MESSAGE = "whatsapp_message"
112
+ WHATSAPP_ATTACHMENT = "whatsapp_attachment"
113
+ PRODUCT = "product"
114
+ FILE = "file"
115
+ TASK = "task"
116
+ TRELLO_BOARD = "trello_board"
117
+ TRELLO_CARD = "trello_card"
118
+ TRELLO_LABEL = "trello_label"
119
+ MRW_SHIPMENT = "mrw_shipment"
120
+ MRW_LABEL = "mrw_label"
121
+
122
+
123
+ class CapabilityRequirement(BaseModel):
124
+ """Runtime requirement evaluated by the host before exposing a capability."""
125
+
126
+ authority: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
127
+ connection_id: str | None = Field(default=None, pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
128
+ audience: list[str] = Field(default_factory=list)
129
+
130
+ model_config = {"extra": "forbid"}
131
+
132
+ @field_validator("audience")
133
+ @classmethod
134
+ def _validate_audience(cls, value: list[str]) -> list[str]:
135
+ return list(dict.fromkeys(item.strip() for item in value if item.strip()))
136
+
137
+
138
+ class ResourceBinding(BaseModel):
139
+ """A resource consumed or produced by a capability."""
140
+
141
+ name: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
142
+ resource_type: ResourceType | str
143
+ required: bool = True
144
+ many: bool = False
145
+
146
+ model_config = {"extra": "forbid"}
147
+
148
+ @field_validator("resource_type")
149
+ @classmethod
150
+ def _validate_resource_type(cls, value: ResourceType | str) -> ResourceType | str:
151
+ _validate_resource_type_id(value)
152
+ return value
153
+
154
+
155
+ class CapabilityInputBinding(BaseModel):
156
+ """Maps a semantic input to one plugin tool argument."""
157
+
158
+ semantic_field: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
159
+ argument_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
160
+ resource_type: ResourceType | str | None = None
161
+ required: bool = True
162
+
163
+ model_config = {"extra": "forbid"}
164
+
165
+ @field_validator("resource_type")
166
+ @classmethod
167
+ def _validate_resource_type(cls, value: ResourceType | str | None) -> ResourceType | str | None:
168
+ if value is not None:
169
+ _validate_resource_type_id(value)
170
+ return value
171
+
172
+
173
+ class CapabilityOutputBinding(BaseModel):
174
+ """Maps one plugin result field to a typed resource output."""
175
+
176
+ field_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$")
177
+ resource_type: ResourceType | str
178
+ output_name: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
179
+ many: bool = False
180
+
181
+ model_config = {"extra": "forbid"}
182
+
183
+ @field_validator("resource_type")
184
+ @classmethod
185
+ def _validate_resource_type(cls, value: ResourceType | str) -> ResourceType | str:
186
+ _validate_resource_type_id(value)
187
+ return value
188
+
189
+
190
+ def _validate_resource_type_id(value: ResourceType | str) -> None:
191
+ import re
192
+
193
+ raw = value.value if isinstance(value, ResourceType) else str(value)
194
+ if not re.fullmatch(_RESOURCE_TYPE_PATTERN, raw):
195
+ raise ValueError("resource type must be a stable lowercase identifier")
196
+
197
+
198
+ class WorldProjectionDescriptor(BaseModel):
199
+ """Named provider projection; payload fields remain provider-owned."""
200
+
201
+ id: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,63}$")
202
+ title: str = Field(..., min_length=1, max_length=160)
203
+ description: str = Field(default="", max_length=1_000)
204
+ fields: list[str] = Field(default_factory=list, max_length=64)
205
+ sensitivity: str = Field(default="safe", pattern=r"^(safe|authorized)$")
206
+
207
+ model_config = {"extra": "forbid"}
208
+
209
+
210
+ class ResourceTypeDescriptor(BaseModel):
211
+ """How one provider exposes a stable resource namespace to the world."""
212
+
213
+ id: ResourceType | str
214
+ title: str = Field(..., min_length=1, max_length=160)
215
+ description: str = Field(..., min_length=1, max_length=2_000)
216
+ identity_fields: list[str] = Field(default_factory=list, min_length=1, max_length=16)
217
+ version_field: str | None = Field(default=None, max_length=128)
218
+ search_capability: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
219
+ inspect_capability: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
220
+ ttl_seconds: int = Field(default=300, ge=0, le=86_400)
221
+ projections: list[WorldProjectionDescriptor] = Field(default_factory=list)
222
+
223
+ model_config = {"extra": "forbid"}
224
+
225
+ @field_validator("id")
226
+ @classmethod
227
+ def _validate_id(cls, value: ResourceType | str) -> ResourceType | str:
228
+ _validate_resource_type_id(value)
229
+ return value
230
+
231
+ @model_validator(mode="after")
232
+ def _validate_projections(self) -> "ResourceTypeDescriptor":
233
+ ids = [item.id for item in self.projections]
234
+ if len(ids) != len(set(ids)):
235
+ raise ValueError("resource projections must not repeat IDs")
236
+ return self
237
+
238
+
239
+ class WorldProviderDescriptor(BaseModel):
240
+ """Static contract projected into the host-owned agent world graph."""
241
+
242
+ id: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
243
+ title: str = Field(..., min_length=1, max_length=160)
244
+ description: str = Field(..., min_length=1, max_length=2_000)
245
+ connection_id: str | None = Field(
246
+ default=None, pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$"
247
+ )
248
+ health_capability: str | None = Field(
249
+ default=None, pattern=r"^[a-z][a-z0-9_.-]{0,127}$"
250
+ )
251
+ resource_types: list[ResourceTypeDescriptor] = Field(default_factory=list, min_length=1)
252
+
253
+ model_config = {"extra": "forbid"}
254
+
255
+ @model_validator(mode="after")
256
+ def _validate_resources(self) -> "WorldProviderDescriptor":
257
+ ids = [item.id.value if isinstance(item.id, ResourceType) else str(item.id) for item in self.resource_types]
258
+ if len(ids) != len(set(ids)):
259
+ raise ValueError("world provider resource types must not repeat IDs")
260
+ return self
261
+
262
+
263
+ class CapabilityDescriptor(BaseModel):
264
+ """Declarative capability contract shared by SDK, host, and plugins."""
265
+
266
+ id: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
267
+ title: str = Field(..., min_length=1, max_length=160)
268
+ description: str = Field(..., min_length=1, max_length=2_000)
269
+ effect: CapabilityEffect
270
+ tool: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
271
+ connection_id: str | None = Field(
272
+ default=None, pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$"
273
+ )
274
+ consumes: list[ResourceBinding] = Field(default_factory=list)
275
+ produces: list[CapabilityOutputBinding] = Field(default_factory=list)
276
+ inputs: list[CapabilityInputBinding] = Field(default_factory=list)
277
+ requirements: CapabilityRequirement = Field(default_factory=CapabilityRequirement)
278
+ model_callable: bool = True
279
+ exposure: CapabilityExposure | None = None
280
+ non_callable_reason: NonCallableReason | None = None
281
+ prepared_action: PreparedActionDescriptor | None = None
282
+ idempotency: IdempotencyDescriptor | None = None
283
+ completion: CompletionDescriptor | None = None
284
+ reviewable_action_id: str | None = Field(
285
+ default=None, pattern=r"^[a-z][a-z0-9-]{0,63}$"
286
+ )
287
+
288
+ model_config = {"extra": "forbid"}
289
+
290
+ @model_validator(mode="after")
291
+ def _validate_contract(self) -> "CapabilityDescriptor":
292
+ if self.exposure is None:
293
+ self.exposure = (
294
+ CapabilityExposure.MODEL if self.model_callable else CapabilityExposure.HOST
295
+ )
296
+ if not self.model_callable and self.non_callable_reason is None:
297
+ self.non_callable_reason = NonCallableReason(
298
+ code="legacy_host_only",
299
+ safe_summary="This capability is available only through the trusted host.",
300
+ )
301
+ if self.exposure == CapabilityExposure.MODEL and not self.model_callable:
302
+ raise ValueError("exposure=model requires model_callable=true")
303
+ if self.exposure != CapabilityExposure.MODEL:
304
+ if self.model_callable:
305
+ raise ValueError("non-model exposure requires model_callable=false")
306
+ if self.non_callable_reason is None:
307
+ raise ValueError("non-model exposure requires non_callable_reason")
308
+ elif self.non_callable_reason is not None:
309
+ raise ValueError("model exposure must not declare non_callable_reason")
310
+ if self.connection_id and self.requirements.connection_id not in (None, self.connection_id):
311
+ raise ValueError("capability connection_id conflicts with its requirement")
312
+ if (
313
+ self.reviewable_action_id
314
+ and self.effect == CapabilityEffect.EXTERNAL_WRITE
315
+ and self.model_callable
316
+ ):
317
+ raise ValueError("reviewable delivery capabilities must be host-only")
318
+ input_names = [item.semantic_field for item in self.inputs]
319
+ if len(input_names) != len(set(input_names)):
320
+ raise ValueError("capability inputs must not repeat semantic fields")
321
+ output_names = [item.output_name for item in self.produces]
322
+ if len(output_names) != len(set(output_names)):
323
+ raise ValueError("capability outputs must not repeat output names")
324
+ if self.effect == CapabilityEffect.EXTERNAL_WRITE and self.model_callable:
325
+ missing = [
326
+ name
327
+ for name, value in (
328
+ ("prepared_action", self.prepared_action),
329
+ ("idempotency", self.idempotency),
330
+ ("completion", self.completion),
331
+ )
332
+ if value is None
333
+ ]
334
+ if missing:
335
+ raise ValueError(
336
+ "model-selectable external writes require " + ", ".join(missing)
337
+ )
338
+ assert self.idempotency is not None
339
+ if not self.idempotency.required_for_execution:
340
+ raise ValueError(
341
+ "model-selectable external writes require execution idempotency"
342
+ )
343
+ return self
344
+
345
+
346
+ def normalize_capability_map(value: Any) -> list[CapabilityDescriptor]:
347
+ """Parse a manifest capability list with a useful validation error."""
348
+ if not isinstance(value, list):
349
+ raise ValueError("capabilities must be a list of capability descriptors")
350
+ return [CapabilityDescriptor.model_validate(item) for item in value]
@@ -17,7 +17,7 @@ from typing import Any, Dict, List, Optional
17
17
 
18
18
  from pydantic import BaseModel, Field, field_validator, model_validator
19
19
 
20
- from .capabilities import CapabilityDescriptor, CapabilityEffect
20
+ from .capabilities import CapabilityDescriptor, CapabilityEffect, ResourceType, WorldProviderDescriptor
21
21
 
22
22
  _SEMVER_RE = re.compile(
23
23
  r"^(0|[1-9]\d*)\."
@@ -325,7 +325,7 @@ class PluginAdminFlow(BaseModel):
325
325
  class PluginManifest(BaseModel):
326
326
  """Manifest stored in plugin.json — the single source of truth for plugin metadata."""
327
327
 
328
- schema_version: str = Field(..., pattern=r"^2\.0$")
328
+ schema_version: str = Field(..., pattern=r"^2\.(0|1)$")
329
329
  id: str = Field(..., pattern=r"^[a-z][a-z0-9\-]*$", max_length=64)
330
330
  name: str = Field(..., min_length=1, max_length=128)
331
331
  version: str = Field(..., min_length=5, max_length=64)
@@ -339,6 +339,7 @@ class PluginManifest(BaseModel):
339
339
  optional_secrets: List[str] = Field(default_factory=list)
340
340
  remote_endpoints: List[str] = Field(default_factory=list)
341
341
  capabilities: List[CapabilityDescriptor] = Field(default_factory=list)
342
+ world_providers: List[WorldProviderDescriptor] = Field(default_factory=list)
342
343
  tags: List[str] = Field(default_factory=list)
343
344
  reviewable_actions: List[ReviewableActionContract] = Field(default_factory=list)
344
345
  admin_extensions: List[PluginAdminExtension] = Field(default_factory=list)
@@ -376,6 +377,26 @@ class PluginManifest(BaseModel):
376
377
  if len(action_ids_list) != len(set(action_ids_list)):
377
378
  raise ValueError("reviewable_actions must not contain duplicate contract IDs")
378
379
  capability_map = {capability.id: capability for capability in self.capabilities}
380
+ provider_ids = [provider.id for provider in self.world_providers]
381
+ if len(provider_ids) != len(set(provider_ids)):
382
+ raise ValueError("world_providers must not contain duplicate IDs")
383
+ for provider in self.world_providers:
384
+ for resource in provider.resource_types:
385
+ resource_id = resource.id.value if isinstance(resource.id, ResourceType) else str(resource.id)
386
+ for field_name, capability_id in (
387
+ ("search_capability", resource.search_capability),
388
+ ("inspect_capability", resource.inspect_capability),
389
+ ):
390
+ if capability_id and capability_id not in capability_map:
391
+ raise ValueError(
392
+ f"world provider resource {resource_id!r} {field_name} references "
393
+ f"unknown capability {capability_id!r}"
394
+ )
395
+ if provider.health_capability and provider.health_capability not in capability_map:
396
+ raise ValueError(
397
+ f"world provider {provider.id!r} references unknown health capability "
398
+ f"{provider.health_capability!r}"
399
+ )
379
400
  action_ids = set(action_ids_list)
380
401
  for action in self.reviewable_actions:
381
402
  execute = capability_map.get(action.execute_capability)
@@ -25,7 +25,7 @@ class PackagingError(Exception):
25
25
  SCAFFOLD_TEMPLATE = {
26
26
  "plugin.json": lambda plugin_id, name: json.dumps(
27
27
  {
28
- "schema_version": "2.0",
28
+ "schema_version": "2.1",
29
29
  "id": plugin_id,
30
30
  "name": name,
31
31
  "version": "0.1.0",
@@ -0,0 +1,97 @@
1
+ """Capability exposure and effect-safety contracts introduced in SDK 0.2.2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from pydantic import ValidationError
7
+
8
+ from nutria_plugin import CapabilityDescriptor, CapabilityExposure
9
+
10
+
11
+ def _capability(**overrides):
12
+ payload = {
13
+ "id": "shipping.create",
14
+ "title": "Create shipment",
15
+ "description": "Create one shipment after an exact preview.",
16
+ "effect": "external_write",
17
+ "tool": "create_shipment",
18
+ }
19
+ payload.update(overrides)
20
+ return payload
21
+
22
+
23
+ def _safety_contracts():
24
+ return {
25
+ "prepared_action": {
26
+ "preview_argument": "preview_only",
27
+ "preview_value": True,
28
+ "execute_value": False,
29
+ "adapter": "exact_preview",
30
+ "ttl_seconds": 3600,
31
+ "merge_previews": True,
32
+ "guard_mode": "pending_only",
33
+ "argument_default": True,
34
+ },
35
+ "idempotency": {
36
+ "argument_name": "idempotency_key",
37
+ "required_for_execution": True,
38
+ },
39
+ "completion": {"receipts": ["shipping.shipment.created"]},
40
+ }
41
+
42
+
43
+ def test_legacy_model_capability_gets_explicit_model_exposure():
44
+ descriptor = CapabilityDescriptor.model_validate(
45
+ _capability(effect="read", model_callable=True)
46
+ )
47
+ assert descriptor.exposure == CapabilityExposure.MODEL
48
+ assert descriptor.non_callable_reason is None
49
+
50
+
51
+ def test_legacy_non_callable_capability_gets_safe_host_projection():
52
+ descriptor = CapabilityDescriptor.model_validate(
53
+ _capability(model_callable=False)
54
+ )
55
+ assert descriptor.exposure == CapabilityExposure.HOST
56
+ assert descriptor.non_callable_reason.code == "legacy_host_only"
57
+
58
+
59
+ @pytest.mark.parametrize("exposure", ["host", "admin", "deprecated"])
60
+ def test_non_model_exposure_requires_reason(exposure):
61
+ with pytest.raises(ValidationError, match="non_callable_reason"):
62
+ CapabilityDescriptor.model_validate(
63
+ _capability(exposure=exposure, model_callable=False)
64
+ )
65
+
66
+
67
+ def test_model_exposure_requires_model_callable():
68
+ with pytest.raises(ValidationError, match="model_callable"):
69
+ CapabilityDescriptor.model_validate(
70
+ _capability(exposure="model", model_callable=False)
71
+ )
72
+
73
+
74
+ def test_model_selectable_external_write_requires_all_safety_contracts():
75
+ with pytest.raises(ValidationError, match="prepared_action"):
76
+ CapabilityDescriptor.model_validate(_capability())
77
+
78
+ descriptor = CapabilityDescriptor.model_validate(
79
+ _capability(**_safety_contracts())
80
+ )
81
+ assert descriptor.prepared_action.adapter == "exact_preview"
82
+ assert descriptor.idempotency.required_for_execution is True
83
+ assert descriptor.completion.receipts == ["shipping.shipment.created"]
84
+
85
+
86
+ def test_host_external_write_remains_valid_without_prepared_contract():
87
+ descriptor = CapabilityDescriptor.model_validate(
88
+ _capability(
89
+ exposure="host",
90
+ model_callable=False,
91
+ non_callable_reason={
92
+ "code": "prepared_execution_only",
93
+ "safe_summary": "Execution is restricted to the prepared-action host.",
94
+ },
95
+ )
96
+ )
97
+ assert descriptor.exposure == CapabilityExposure.HOST
@@ -64,7 +64,7 @@ def test_version_must_be_semver():
64
64
  PluginManifest.model_validate(_minimal_manifest(version="1.0"))
65
65
 
66
66
 
67
- def test_schema_version_must_be_2_0():
67
+ def test_schema_version_must_be_2_x():
68
68
  with pytest.raises(ValidationError):
69
69
  PluginManifest.model_validate(_minimal_manifest(schema_version="1.1"))
70
70
 
@@ -74,6 +74,80 @@ def test_schema_version_1_x_is_rejected():
74
74
  PluginManifest.model_validate(_minimal_manifest(schema_version="1.0"))
75
75
 
76
76
 
77
+ def test_schema_2_1_world_provider_accepts_custom_resource_type():
78
+ manifest = PluginManifest.model_validate(
79
+ _minimal_manifest(
80
+ schema_version="2.1",
81
+ capabilities=[
82
+ {
83
+ "id": "workspace.search",
84
+ "title": "Search workspace",
85
+ "description": "Find authoritative workspace items.",
86
+ "effect": "read",
87
+ "tool": "search_workspace",
88
+ "connection_id": "workspace",
89
+ "produces": [
90
+ {
91
+ "field_name": "items",
92
+ "resource_type": "workspace.item",
93
+ "output_name": "items",
94
+ "many": True,
95
+ }
96
+ ],
97
+ }
98
+ ],
99
+ world_providers=[
100
+ {
101
+ "id": "workspace",
102
+ "title": "Workspace",
103
+ "description": "Authoritative workspace provider.",
104
+ "connection_id": "workspace",
105
+ "resource_types": [
106
+ {
107
+ "id": "workspace.item",
108
+ "title": "Workspace item",
109
+ "description": "A stable workspace item.",
110
+ "identity_fields": ["id"],
111
+ "search_capability": "workspace.search",
112
+ "projections": [
113
+ {"id": "admin", "title": "Admin", "fields": ["id"]}
114
+ ],
115
+ }
116
+ ],
117
+ }
118
+ ],
119
+ )
120
+ )
121
+
122
+ assert manifest.schema_version == "2.1"
123
+ assert manifest.world_providers[0].resource_types[0].id == "workspace.item"
124
+
125
+
126
+ def test_world_provider_rejects_unknown_capability_reference():
127
+ with pytest.raises(ValidationError, match="unknown capability"):
128
+ PluginManifest.model_validate(
129
+ _minimal_manifest(
130
+ schema_version="2.1",
131
+ world_providers=[
132
+ {
133
+ "id": "workspace",
134
+ "title": "Workspace",
135
+ "description": "Authoritative workspace provider.",
136
+ "resource_types": [
137
+ {
138
+ "id": "workspace.item",
139
+ "title": "Workspace item",
140
+ "description": "A stable workspace item.",
141
+ "identity_fields": ["id"],
142
+ "search_capability": "workspace.missing",
143
+ }
144
+ ],
145
+ }
146
+ ],
147
+ )
148
+ )
149
+
150
+
77
151
  def test_empty_runtime_types_rejected():
78
152
  with pytest.raises(ValidationError):
79
153
  PluginManifest.model_validate(_minimal_manifest(runtime_types=[]))
@@ -262,7 +262,7 @@ wheels = [
262
262
 
263
263
  [[package]]
264
264
  name = "nutria-plugin"
265
- version = "0.1.0"
265
+ version = "0.2.2"
266
266
  source = { editable = "." }
267
267
  dependencies = [
268
268
  { name = "cryptography" },
@@ -1,142 +0,0 @@
1
- """Typed resource and capability contracts for Nutria plugin manifests."""
2
-
3
- from __future__ import annotations
4
-
5
- from enum import Enum
6
- from typing import Any
7
-
8
- from pydantic import BaseModel, Field, field_validator, model_validator
9
-
10
-
11
- class CapabilityEffect(str, Enum):
12
- READ = "read"
13
- PREPARE = "prepare"
14
- WRITE = "write"
15
- EXTERNAL_WRITE = "external_write"
16
-
17
-
18
- class ResourceType(str, Enum):
19
- TENANT = "tenant"
20
- CLIENT = "client"
21
- BRAND = "brand"
22
- AGENT = "agent"
23
- SERVICE_PRINCIPAL = "service_principal"
24
- HUMAN_ACTOR = "human_actor"
25
- CUSTOMER_SESSION = "customer_session"
26
- CONNECTOR = "connector"
27
- TEAM = "team"
28
- AUTHORITY_GRANT = "authority_grant"
29
- CONVERSATION = "conversation"
30
- CHANNEL_CONVERSATION = "channel_conversation"
31
- MESSAGE = "message"
32
- CHANNEL_EVENT = "channel_event"
33
- ATTACHMENT = "attachment"
34
- CASE = "case"
35
- ORDER = "order"
36
- PREPARED_ACTION = "prepared_action"
37
- OPERATION = "operation"
38
- RESPONSE = "response"
39
- ARTIFACT = "artifact"
40
- CAPABILITY = "capability"
41
- CUSTOMER = "customer"
42
- EMAIL_THREAD = "email_thread"
43
- EMAIL_MESSAGE = "email_message"
44
- WHATSAPP_CHAT = "whatsapp_chat"
45
- WHATSAPP_MESSAGE = "whatsapp_message"
46
- WHATSAPP_ATTACHMENT = "whatsapp_attachment"
47
-
48
-
49
- class CapabilityRequirement(BaseModel):
50
- """Runtime requirement evaluated by the host before exposing a capability."""
51
-
52
- authority: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
53
- connection_id: str | None = Field(default=None, pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
54
- audience: list[str] = Field(default_factory=list)
55
-
56
- model_config = {"extra": "forbid"}
57
-
58
- @field_validator("audience")
59
- @classmethod
60
- def _validate_audience(cls, value: list[str]) -> list[str]:
61
- return list(dict.fromkeys(item.strip() for item in value if item.strip()))
62
-
63
-
64
- class ResourceBinding(BaseModel):
65
- """A resource consumed or produced by a capability."""
66
-
67
- name: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
68
- resource_type: ResourceType
69
- required: bool = True
70
- many: bool = False
71
-
72
- model_config = {"extra": "forbid"}
73
-
74
-
75
- class CapabilityInputBinding(BaseModel):
76
- """Maps a semantic input to one plugin tool argument."""
77
-
78
- semantic_field: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
79
- argument_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
80
- resource_type: ResourceType | None = None
81
- required: bool = True
82
-
83
- model_config = {"extra": "forbid"}
84
-
85
-
86
- class CapabilityOutputBinding(BaseModel):
87
- """Maps one plugin result field to a typed resource output."""
88
-
89
- field_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$")
90
- resource_type: ResourceType
91
- output_name: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
92
- many: bool = False
93
-
94
- model_config = {"extra": "forbid"}
95
-
96
-
97
- class CapabilityDescriptor(BaseModel):
98
- """Declarative capability contract shared by SDK, host, and plugins."""
99
-
100
- id: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
101
- title: str = Field(..., min_length=1, max_length=160)
102
- description: str = Field(..., min_length=1, max_length=2_000)
103
- effect: CapabilityEffect
104
- tool: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
105
- connection_id: str | None = Field(
106
- default=None, pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$"
107
- )
108
- consumes: list[ResourceBinding] = Field(default_factory=list)
109
- produces: list[CapabilityOutputBinding] = Field(default_factory=list)
110
- inputs: list[CapabilityInputBinding] = Field(default_factory=list)
111
- requirements: CapabilityRequirement = Field(default_factory=CapabilityRequirement)
112
- model_callable: bool = True
113
- reviewable_action_id: str | None = Field(
114
- default=None, pattern=r"^[a-z][a-z0-9-]{0,63}$"
115
- )
116
-
117
- model_config = {"extra": "forbid"}
118
-
119
- @model_validator(mode="after")
120
- def _validate_contract(self) -> "CapabilityDescriptor":
121
- if self.connection_id and self.requirements.connection_id not in (None, self.connection_id):
122
- raise ValueError("capability connection_id conflicts with its requirement")
123
- if (
124
- self.reviewable_action_id
125
- and self.effect == CapabilityEffect.EXTERNAL_WRITE
126
- and self.model_callable
127
- ):
128
- raise ValueError("reviewable delivery capabilities must be host-only")
129
- input_names = [item.semantic_field for item in self.inputs]
130
- if len(input_names) != len(set(input_names)):
131
- raise ValueError("capability inputs must not repeat semantic fields")
132
- output_names = [item.output_name for item in self.produces]
133
- if len(output_names) != len(set(output_names)):
134
- raise ValueError("capability outputs must not repeat output names")
135
- return self
136
-
137
-
138
- def normalize_capability_map(value: Any) -> list[CapabilityDescriptor]:
139
- """Parse a manifest capability list with a useful validation error."""
140
- if not isinstance(value, list):
141
- raise ValueError("capabilities must be a list of capability descriptors")
142
- return [CapabilityDescriptor.model_validate(item) for item in value]
File without changes
File without changes