nutria-plugin 0.2.3__tar.gz → 0.3.0__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.
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/CHANGELOG.md +6 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/PKG-INFO +13 -7
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/README.md +12 -6
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/index.md +3 -3
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/manifest.md +4 -4
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/python-api.md +2 -2
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/quickstart.md +9 -4
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/reviewable-actions.md +4 -4
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/pyproject.toml +2 -1
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/__init__.py +1 -1
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/capabilities.py +25 -7
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/manifest.py +25 -1
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/packaging.py +8 -1
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_bundle.py +9 -2
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_capability_contracts.py +70 -8
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_manifest.py +28 -8
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_reviewable_actions.py +24 -3
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_signing.py +1 -1
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/uv.lock +3 -1
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/.github/workflows/publish.yml +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/.gitignore +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/admin-extensions.md +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/admin-flows.md +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/cli.md +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/connection-types.md +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/security.md +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/skill-format.md +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/examples/my-first-plugin/README.md +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/examples/my-first-plugin/hooks/hooks.json +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/examples/my-first-plugin/plugin.json +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/examples/my-first-plugin/settings.schema.json +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/bundle.py +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/cli.py +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/signing.py +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_cli.py +0 -0
- {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_packaging.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Replace adapter-specific prepared actions with one portable exact-preview protocol.
|
|
6
|
+
- Require typed value/resource inputs with explicit origin and sensitivity.
|
|
7
|
+
- Require JSON result paths for model-visible read outputs and reject schema 2.2.
|
|
8
|
+
|
|
3
9
|
## 0.2.3
|
|
4
10
|
|
|
5
11
|
- Make manifest schema 2.2 the only accepted plugin contract.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nutria-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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,8 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
|
|
22
22
|
SDK for building, validating, signing, and packaging Nutria plugins.
|
|
23
23
|
|
|
24
|
-
Release `0.
|
|
25
|
-
`
|
|
24
|
+
Release `0.3.0` is intentionally breaking. It accepts only manifest schema
|
|
25
|
+
`3.0`, requires typed capabilities and world providers, and makes authority,
|
|
26
26
|
audience, task-context requirements, and exposure explicit. There is no runtime
|
|
27
27
|
migration for schema 2.0/2.1 manifests and no compatibility/model-callability
|
|
28
28
|
field.
|
|
@@ -34,7 +34,7 @@ and exact delivery of an approved snapshot.
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
uv add nutria-plugin==0.
|
|
37
|
+
uv add nutria-plugin==0.3.0
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
## Scaffold and validate
|
|
@@ -49,11 +49,11 @@ The generated directory contains `plugin.json`, component directories for
|
|
|
49
49
|
connections, skills, context documents, specs and hooks, plus an optional
|
|
50
50
|
settings schema and assets.
|
|
51
51
|
|
|
52
|
-
## Minimal schema
|
|
52
|
+
## Minimal schema 3.0 manifest
|
|
53
53
|
|
|
54
54
|
```json
|
|
55
55
|
{
|
|
56
|
-
"schema_version": "
|
|
56
|
+
"schema_version": "3.0",
|
|
57
57
|
"id": "my-workspace-plugin",
|
|
58
58
|
"name": "My Workspace Plugin",
|
|
59
59
|
"version": "0.1.0",
|
|
@@ -74,7 +74,13 @@ settings schema and assets.
|
|
|
74
74
|
"audience": ["team_internal"],
|
|
75
75
|
"task_context": "optional"
|
|
76
76
|
},
|
|
77
|
-
"exposure": "model"
|
|
77
|
+
"exposure": "model",
|
|
78
|
+
"produces": [{
|
|
79
|
+
"result_path": ".items",
|
|
80
|
+
"resource_type": "workspace.item",
|
|
81
|
+
"output_name": "items",
|
|
82
|
+
"many": true
|
|
83
|
+
}]
|
|
78
84
|
}],
|
|
79
85
|
"world_providers": [{
|
|
80
86
|
"id": "workspace",
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
SDK for building, validating, signing, and packaging Nutria plugins.
|
|
4
4
|
|
|
5
|
-
Release `0.
|
|
6
|
-
`
|
|
5
|
+
Release `0.3.0` is intentionally breaking. It accepts only manifest schema
|
|
6
|
+
`3.0`, requires typed capabilities and world providers, and makes authority,
|
|
7
7
|
audience, task-context requirements, and exposure explicit. There is no runtime
|
|
8
8
|
migration for schema 2.0/2.1 manifests and no compatibility/model-callability
|
|
9
9
|
field.
|
|
@@ -15,7 +15,7 @@ and exact delivery of an approved snapshot.
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
uv add nutria-plugin==0.
|
|
18
|
+
uv add nutria-plugin==0.3.0
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Scaffold and validate
|
|
@@ -30,11 +30,11 @@ The generated directory contains `plugin.json`, component directories for
|
|
|
30
30
|
connections, skills, context documents, specs and hooks, plus an optional
|
|
31
31
|
settings schema and assets.
|
|
32
32
|
|
|
33
|
-
## Minimal schema
|
|
33
|
+
## Minimal schema 3.0 manifest
|
|
34
34
|
|
|
35
35
|
```json
|
|
36
36
|
{
|
|
37
|
-
"schema_version": "
|
|
37
|
+
"schema_version": "3.0",
|
|
38
38
|
"id": "my-workspace-plugin",
|
|
39
39
|
"name": "My Workspace Plugin",
|
|
40
40
|
"version": "0.1.0",
|
|
@@ -55,7 +55,13 @@ settings schema and assets.
|
|
|
55
55
|
"audience": ["team_internal"],
|
|
56
56
|
"task_context": "optional"
|
|
57
57
|
},
|
|
58
|
-
"exposure": "model"
|
|
58
|
+
"exposure": "model",
|
|
59
|
+
"produces": [{
|
|
60
|
+
"result_path": ".items",
|
|
61
|
+
"resource_type": "workspace.item",
|
|
62
|
+
"output_name": "items",
|
|
63
|
+
"many": true
|
|
64
|
+
}]
|
|
59
65
|
}],
|
|
60
66
|
"world_providers": [{
|
|
61
67
|
"id": "workspace",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# nutria-plugin SDK documentation
|
|
2
2
|
|
|
3
|
-
Developer reference for SDK `0.
|
|
3
|
+
Developer reference for SDK `0.3.0` and manifest schema `3.0`.
|
|
4
4
|
|
|
5
5
|
| Document | Purpose |
|
|
6
6
|
|---|---|
|
|
7
|
-
| [quickstart.md](quickstart.md) | Build a strict schema
|
|
7
|
+
| [quickstart.md](quickstart.md) | Build a strict schema 3.0 plugin |
|
|
8
8
|
| [manifest.md](manifest.md) | Manifest, capability, and provider contracts |
|
|
9
9
|
| [reviewable-actions.md](reviewable-actions.md) | Host-owned drafts and external delivery |
|
|
10
10
|
| [python-api.md](python-api.md) | Public Python API |
|
|
@@ -16,6 +16,6 @@ Developer reference for SDK `0.2.3` and manifest schema `2.2`.
|
|
|
16
16
|
| [cli.md](cli.md) | CLI commands |
|
|
17
17
|
|
|
18
18
|
A plugin bundle can contain connections, skills, context documents, hooks,
|
|
19
|
-
settings, specs, and declarative admin assets. Its schema
|
|
19
|
+
settings, specs, and declarative admin assets. Its schema 3.0 manifest must also
|
|
20
20
|
declare at least one typed capability and world provider. Older schemas are not
|
|
21
21
|
loaded or migrated.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# plugin.json manifest reference
|
|
2
2
|
|
|
3
3
|
`plugin.json` is the single source of truth for plugin identity, runtime,
|
|
4
|
-
capability authority, and provider topology. SDK 0.
|
|
5
|
-
`
|
|
4
|
+
capability authority, and provider topology. SDK 0.3.0 accepts exactly schema
|
|
5
|
+
`3.0`; older schemas and unknown fields fail validation.
|
|
6
6
|
|
|
7
7
|
## Required top-level fields
|
|
8
8
|
|
|
9
9
|
| Field | Contract |
|
|
10
10
|
|---|---|
|
|
11
|
-
| `schema_version` | Literal `"
|
|
11
|
+
| `schema_version` | Literal `"3.0"` |
|
|
12
12
|
| `id` | Lowercase plugin slug |
|
|
13
13
|
| `name`, `description`, `author` | Non-empty display metadata |
|
|
14
14
|
| `version` | Semantic version |
|
|
@@ -19,7 +19,7 @@ capability authority, and provider topology. SDK 0.2.3 accepts exactly schema
|
|
|
19
19
|
Optional fields include `default_scope`, `paths`, `required_secrets`,
|
|
20
20
|
`optional_secrets`, `remote_endpoints`, `tags`, `reviewable_actions`,
|
|
21
21
|
`admin_extensions`, `admin_flows`, `mcp_server_entry`, `homepage`, `license`,
|
|
22
|
-
and `signature`. A `compatibility` field is not part of schema
|
|
22
|
+
and `signature`. A `compatibility` field is not part of schema 3.0.
|
|
23
23
|
|
|
24
24
|
## Capability descriptor
|
|
25
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Python API reference
|
|
2
2
|
|
|
3
|
-
SDK 0.
|
|
3
|
+
SDK 0.3.0 exposes the strict schema 3.0 API.
|
|
4
4
|
|
|
5
5
|
```python
|
|
6
6
|
from nutria_plugin import (
|
|
@@ -39,7 +39,7 @@ manifest.to_file(Path("plugin.json"))
|
|
|
39
39
|
payload = manifest.model_dump(mode="json", exclude_none=True)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Validation is strict: schema version must be `
|
|
42
|
+
Validation is strict: schema version must be `3.0`, top-level extras are
|
|
43
43
|
rejected, and capability/provider/action references are checked together.
|
|
44
44
|
|
|
45
45
|
## Bundles and packaging
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# Quickstart: a schema
|
|
1
|
+
# Quickstart: a schema 3.0 plugin
|
|
2
2
|
|
|
3
3
|
## Install and scaffold
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
uv add nutria-plugin==0.
|
|
6
|
+
uv add nutria-plugin==0.3.0
|
|
7
7
|
nutria-plugin new inventory-lookup --name "Inventory Lookup"
|
|
8
8
|
```
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@ nutria-plugin new inventory-lookup --name "Inventory Lookup"
|
|
|
11
11
|
|
|
12
12
|
```json
|
|
13
13
|
{
|
|
14
|
-
"schema_version": "
|
|
14
|
+
"schema_version": "3.0",
|
|
15
15
|
"id": "inventory-lookup",
|
|
16
16
|
"name": "Inventory Lookup",
|
|
17
17
|
"version": "0.1.0",
|
|
@@ -32,7 +32,12 @@ nutria-plugin new inventory-lookup --name "Inventory Lookup"
|
|
|
32
32
|
"audience": ["team_internal", "public_customer"],
|
|
33
33
|
"task_context": "optional"
|
|
34
34
|
},
|
|
35
|
-
"exposure": "model"
|
|
35
|
+
"exposure": "model",
|
|
36
|
+
"produces": [{
|
|
37
|
+
"result_path": ".stock",
|
|
38
|
+
"resource_type": "warehouse.stock",
|
|
39
|
+
"output_name": "stock"
|
|
40
|
+
}]
|
|
36
41
|
}],
|
|
37
42
|
"world_providers": [{
|
|
38
43
|
"id": "warehouse",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Reviewable actions and external writes
|
|
2
2
|
|
|
3
|
-
SDK 0.
|
|
3
|
+
SDK 0.3.0 schema 3.0 defines one reviewable-action architecture:
|
|
4
4
|
|
|
5
5
|
```text
|
|
6
6
|
ChatBotNutralia -> task context, encrypted draft, revision, approval, idempotency, receipts
|
|
@@ -36,9 +36,9 @@ requirements, input bindings, and `reviewable_action_id`:
|
|
|
36
36
|
},
|
|
37
37
|
"reviewable_action_id": "whatsapp-text",
|
|
38
38
|
"inputs": [
|
|
39
|
-
{"semantic_field": "recipient", "argument_name": "recipient"},
|
|
40
|
-
{"semantic_field": "body", "argument_name": "body"},
|
|
41
|
-
{"semantic_field": "idempotency_key", "argument_name": "idempotency_key"}
|
|
39
|
+
{"kind": "value", "semantic_field": "recipient", "argument_name": "recipient", "sensitivity": "personal", "accepted_origins": ["current_user", "world_resource"]},
|
|
40
|
+
{"kind": "value", "semantic_field": "body", "argument_name": "body", "sensitivity": "personal", "accepted_origins": ["current_user"]},
|
|
41
|
+
{"kind": "value", "semantic_field": "idempotency_key", "argument_name": "idempotency_key", "sensitivity": "safe", "accepted_origins": ["current_user"]}
|
|
42
42
|
]
|
|
43
43
|
}
|
|
44
44
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nutria-plugin"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "SDK for building, validating, signing, and packaging Nutria plugins"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -48,4 +48,5 @@ target-version = "py311"
|
|
|
48
48
|
dev = [
|
|
49
49
|
"pytest>=9.0.2",
|
|
50
50
|
"pytest-asyncio>=1.3.0",
|
|
51
|
+
"ruff>=0.8",
|
|
51
52
|
]
|
|
@@ -36,16 +36,12 @@ class NonCallableReason(BaseModel):
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
class PreparedActionDescriptor(BaseModel):
|
|
39
|
-
"""
|
|
39
|
+
"""Portable exact-preview contract used by every Nutria host."""
|
|
40
40
|
|
|
41
41
|
preview_argument: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
|
|
42
42
|
preview_value: Any
|
|
43
43
|
execute_value: Any
|
|
44
|
-
adapter: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
45
44
|
ttl_seconds: int = Field(..., ge=60, le=86_400)
|
|
46
|
-
merge_previews: bool
|
|
47
|
-
guard_mode: str = Field(..., pattern=r"^(always|pending_only)$")
|
|
48
|
-
argument_default: Any
|
|
49
45
|
|
|
50
46
|
model_config = {"extra": "forbid"}
|
|
51
47
|
|
|
@@ -152,12 +148,15 @@ class ResourceBinding(BaseModel):
|
|
|
152
148
|
|
|
153
149
|
|
|
154
150
|
class CapabilityInputBinding(BaseModel):
|
|
155
|
-
"""Maps a
|
|
151
|
+
"""Maps a typed value or world resource to one plugin argument."""
|
|
156
152
|
|
|
153
|
+
kind: Literal["value", "resource"]
|
|
157
154
|
semantic_field: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
158
155
|
argument_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
|
|
159
156
|
resource_type: ResourceType | str | None = None
|
|
160
157
|
required: bool = True
|
|
158
|
+
sensitivity: Literal["safe", "personal"] = "safe"
|
|
159
|
+
accepted_origins: list[Literal["current_user", "world_resource"]] = Field(min_length=1)
|
|
161
160
|
|
|
162
161
|
model_config = {"extra": "forbid"}
|
|
163
162
|
|
|
@@ -168,11 +167,21 @@ class CapabilityInputBinding(BaseModel):
|
|
|
168
167
|
_validate_resource_type_id(value)
|
|
169
168
|
return value
|
|
170
169
|
|
|
170
|
+
@model_validator(mode="after")
|
|
171
|
+
def _validate_kind(self) -> "CapabilityInputBinding":
|
|
172
|
+
if self.kind == "resource" and self.resource_type is None:
|
|
173
|
+
raise ValueError("resource inputs require resource_type")
|
|
174
|
+
if self.kind == "value" and self.resource_type is not None:
|
|
175
|
+
raise ValueError("value inputs must not declare resource_type")
|
|
176
|
+
if self.sensitivity == "personal" and "current_user" not in self.accepted_origins:
|
|
177
|
+
raise ValueError("personal inputs must accept current_user evidence")
|
|
178
|
+
return self
|
|
179
|
+
|
|
171
180
|
|
|
172
181
|
class CapabilityOutputBinding(BaseModel):
|
|
173
182
|
"""Maps one plugin result field to a typed resource output."""
|
|
174
183
|
|
|
175
|
-
|
|
184
|
+
result_path: str = Field(..., pattern=r"^\$?(?:\.[a-zA-Z][a-zA-Z0-9_-]*)+$")
|
|
176
185
|
resource_type: ResourceType | str
|
|
177
186
|
output_name: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
178
187
|
many: bool = False
|
|
@@ -335,6 +344,15 @@ class CapabilityDescriptor(BaseModel):
|
|
|
335
344
|
raise ValueError(
|
|
336
345
|
"model-selectable external writes require execution idempotency"
|
|
337
346
|
)
|
|
347
|
+
if self.exposure == CapabilityExposure.MODEL and self.effect == CapabilityEffect.READ:
|
|
348
|
+
if not self.produces:
|
|
349
|
+
raise ValueError("model-selectable reads require declared outputs")
|
|
350
|
+
if self.exposure == CapabilityExposure.MODEL and self.effect in {
|
|
351
|
+
CapabilityEffect.WRITE,
|
|
352
|
+
CapabilityEffect.EXTERNAL_WRITE,
|
|
353
|
+
}:
|
|
354
|
+
if not self.inputs:
|
|
355
|
+
raise ValueError("model-selectable writes require declared inputs")
|
|
338
356
|
return self
|
|
339
357
|
|
|
340
358
|
|
|
@@ -315,7 +315,7 @@ class PluginAdminFlow(BaseModel):
|
|
|
315
315
|
class PluginManifest(BaseModel):
|
|
316
316
|
"""Manifest stored in plugin.json — the single source of truth for plugin metadata."""
|
|
317
317
|
|
|
318
|
-
schema_version: Literal["
|
|
318
|
+
schema_version: Literal["3.0"]
|
|
319
319
|
id: str = Field(..., pattern=r"^[a-z][a-z0-9\-]*$", max_length=64)
|
|
320
320
|
name: str = Field(..., min_length=1, max_length=128)
|
|
321
321
|
version: str = Field(..., min_length=5, max_length=64)
|
|
@@ -383,6 +383,30 @@ class PluginManifest(BaseModel):
|
|
|
383
383
|
"capability connections require a matching world provider: "
|
|
384
384
|
+ ", ".join(sorted(missing_provider_connections))
|
|
385
385
|
)
|
|
386
|
+
provider_resource_types = {
|
|
387
|
+
provider.connection_id: {
|
|
388
|
+
resource.id.value if isinstance(resource.id, ResourceType) else str(resource.id)
|
|
389
|
+
for resource in provider.resource_types
|
|
390
|
+
}
|
|
391
|
+
for provider in self.world_providers
|
|
392
|
+
if provider.connection_id
|
|
393
|
+
}
|
|
394
|
+
for capability in self.capabilities:
|
|
395
|
+
if not capability.connection_id:
|
|
396
|
+
continue
|
|
397
|
+
available_types = provider_resource_types.get(capability.connection_id, set())
|
|
398
|
+
undeclared = {
|
|
399
|
+
output.resource_type.value
|
|
400
|
+
if isinstance(output.resource_type, ResourceType)
|
|
401
|
+
else str(output.resource_type)
|
|
402
|
+
for output in capability.produces
|
|
403
|
+
} - available_types
|
|
404
|
+
if undeclared:
|
|
405
|
+
raise ValueError(
|
|
406
|
+
f"capability {capability.id!r} produces resource types not declared by "
|
|
407
|
+
f"provider {capability.connection_id!r}: "
|
|
408
|
+
+ ", ".join(sorted(undeclared))
|
|
409
|
+
)
|
|
386
410
|
for provider in self.world_providers:
|
|
387
411
|
for resource in provider.resource_types:
|
|
388
412
|
resource_id = resource.id.value if isinstance(resource.id, ResourceType) else str(resource.id)
|
|
@@ -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": "
|
|
28
|
+
"schema_version": "3.0",
|
|
29
29
|
"id": plugin_id,
|
|
30
30
|
"name": name,
|
|
31
31
|
"version": "0.1.0",
|
|
@@ -49,6 +49,13 @@ SCAFFOLD_TEMPLATE = {
|
|
|
49
49
|
"task_context": "optional",
|
|
50
50
|
},
|
|
51
51
|
"exposure": "model",
|
|
52
|
+
"produces": [
|
|
53
|
+
{
|
|
54
|
+
"result_path": ".health",
|
|
55
|
+
"resource_type": f"{plugin_id}.resource",
|
|
56
|
+
"output_name": "health",
|
|
57
|
+
}
|
|
58
|
+
],
|
|
52
59
|
}
|
|
53
60
|
],
|
|
54
61
|
"world_providers": [
|
|
@@ -28,7 +28,7 @@ def _make_zip(files: dict[str, str]) -> bytes:
|
|
|
28
28
|
|
|
29
29
|
VALID_MANIFEST = json.dumps(
|
|
30
30
|
{
|
|
31
|
-
"schema_version": "
|
|
31
|
+
"schema_version": "3.0",
|
|
32
32
|
"id": "test-plugin",
|
|
33
33
|
"name": "Test",
|
|
34
34
|
"version": "1.0.0",
|
|
@@ -48,7 +48,14 @@ VALID_MANIFEST = json.dumps(
|
|
|
48
48
|
"audience": ["private_internal"],
|
|
49
49
|
"task_context": "optional"
|
|
50
50
|
},
|
|
51
|
-
"exposure": "model"
|
|
51
|
+
"exposure": "model",
|
|
52
|
+
"produces": [
|
|
53
|
+
{
|
|
54
|
+
"result_path": ".health",
|
|
55
|
+
"resource_type": "test.resource",
|
|
56
|
+
"output_name": "health"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
52
59
|
}
|
|
53
60
|
],
|
|
54
61
|
"world_providers": [
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Strict world-graph capability contracts introduced in SDK 0.
|
|
1
|
+
"""Strict world-graph capability contracts introduced in SDK 0.3.0."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -21,6 +21,15 @@ def _capability(**overrides):
|
|
|
21
21
|
"task_context": "optional",
|
|
22
22
|
},
|
|
23
23
|
"exposure": "model",
|
|
24
|
+
"inputs": [
|
|
25
|
+
{
|
|
26
|
+
"kind": "value",
|
|
27
|
+
"semantic_field": "recipient",
|
|
28
|
+
"argument_name": "recipient",
|
|
29
|
+
"sensitivity": "personal",
|
|
30
|
+
"accepted_origins": ["current_user"],
|
|
31
|
+
}
|
|
32
|
+
],
|
|
24
33
|
}
|
|
25
34
|
payload.update(overrides)
|
|
26
35
|
return payload
|
|
@@ -32,11 +41,7 @@ def _safety_contracts():
|
|
|
32
41
|
"preview_argument": "preview_only",
|
|
33
42
|
"preview_value": True,
|
|
34
43
|
"execute_value": False,
|
|
35
|
-
"adapter": "exact_preview",
|
|
36
44
|
"ttl_seconds": 3600,
|
|
37
|
-
"merge_previews": True,
|
|
38
|
-
"guard_mode": "pending_only",
|
|
39
|
-
"argument_default": True,
|
|
40
45
|
},
|
|
41
46
|
"idempotency": {
|
|
42
47
|
"argument_name": "idempotency_key",
|
|
@@ -47,13 +52,33 @@ def _safety_contracts():
|
|
|
47
52
|
|
|
48
53
|
|
|
49
54
|
def test_model_capability_requires_explicit_model_exposure():
|
|
50
|
-
descriptor = CapabilityDescriptor.model_validate(
|
|
55
|
+
descriptor = CapabilityDescriptor.model_validate(
|
|
56
|
+
_capability(
|
|
57
|
+
effect="read",
|
|
58
|
+
produces=[
|
|
59
|
+
{
|
|
60
|
+
"result_path": ".shipment",
|
|
61
|
+
"resource_type": "mrw.shipment",
|
|
62
|
+
"output_name": "shipment",
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
)
|
|
51
67
|
assert descriptor.exposure == CapabilityExposure.MODEL
|
|
52
68
|
assert descriptor.non_callable_reason is None
|
|
53
69
|
|
|
54
70
|
|
|
55
71
|
def test_missing_exposure_is_rejected():
|
|
56
|
-
payload = _capability(
|
|
72
|
+
payload = _capability(
|
|
73
|
+
effect="read",
|
|
74
|
+
produces=[
|
|
75
|
+
{
|
|
76
|
+
"result_path": ".shipment",
|
|
77
|
+
"resource_type": "mrw.shipment",
|
|
78
|
+
"output_name": "shipment",
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
)
|
|
57
82
|
payload.pop("exposure")
|
|
58
83
|
with pytest.raises(ValidationError, match="exposure"):
|
|
59
84
|
CapabilityDescriptor.model_validate(payload)
|
|
@@ -82,7 +107,7 @@ def test_model_selectable_external_write_requires_all_safety_contracts():
|
|
|
82
107
|
descriptor = CapabilityDescriptor.model_validate(
|
|
83
108
|
_capability(**_safety_contracts())
|
|
84
109
|
)
|
|
85
|
-
assert descriptor.prepared_action.
|
|
110
|
+
assert descriptor.prepared_action.preview_argument == "preview_only"
|
|
86
111
|
assert descriptor.idempotency.required_for_execution is True
|
|
87
112
|
assert descriptor.completion.receipts == ["shipping.shipment.created"]
|
|
88
113
|
|
|
@@ -111,5 +136,42 @@ def test_task_owned_resources_require_task_context():
|
|
|
111
136
|
_capability(
|
|
112
137
|
effect="read",
|
|
113
138
|
consumes=[{"name": "action", "resource_type": "prepared_action"}],
|
|
139
|
+
produces=[
|
|
140
|
+
{
|
|
141
|
+
"result_path": ".shipment",
|
|
142
|
+
"resource_type": "mrw.shipment",
|
|
143
|
+
"output_name": "shipment",
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_model_read_requires_typed_output():
|
|
151
|
+
with pytest.raises(ValidationError, match="declared outputs"):
|
|
152
|
+
CapabilityDescriptor.model_validate(_capability(effect="read", inputs=[]))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_unknown_prepared_action_adapter_is_rejected():
|
|
156
|
+
contracts = _safety_contracts()
|
|
157
|
+
contracts["prepared_action"]["adapter"] = "plugin.argument_toggle"
|
|
158
|
+
with pytest.raises(ValidationError, match="adapter"):
|
|
159
|
+
CapabilityDescriptor.model_validate(_capability(**contracts))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_personal_input_requires_current_user_origin():
|
|
163
|
+
with pytest.raises(ValidationError, match="current_user"):
|
|
164
|
+
CapabilityDescriptor.model_validate(
|
|
165
|
+
_capability(
|
|
166
|
+
inputs=[
|
|
167
|
+
{
|
|
168
|
+
"kind": "value",
|
|
169
|
+
"semantic_field": "recipient",
|
|
170
|
+
"argument_name": "recipient",
|
|
171
|
+
"sensitivity": "personal",
|
|
172
|
+
"accepted_origins": ["world_resource"],
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
**_safety_contracts(),
|
|
114
176
|
)
|
|
115
177
|
)
|
|
@@ -18,7 +18,7 @@ from nutria_plugin.manifest import (
|
|
|
18
18
|
|
|
19
19
|
def _minimal_manifest(**overrides) -> dict:
|
|
20
20
|
base = {
|
|
21
|
-
"schema_version": "
|
|
21
|
+
"schema_version": "3.0",
|
|
22
22
|
"id": "test-plugin",
|
|
23
23
|
"name": "Test Plugin",
|
|
24
24
|
"version": "1.0.0",
|
|
@@ -39,6 +39,13 @@ def _minimal_manifest(**overrides) -> dict:
|
|
|
39
39
|
"task_context": "optional",
|
|
40
40
|
},
|
|
41
41
|
"exposure": "model",
|
|
42
|
+
"produces": [
|
|
43
|
+
{
|
|
44
|
+
"result_path": ".health",
|
|
45
|
+
"resource_type": "test.resource",
|
|
46
|
+
"output_name": "health",
|
|
47
|
+
}
|
|
48
|
+
],
|
|
42
49
|
}
|
|
43
50
|
],
|
|
44
51
|
"world_providers": [
|
|
@@ -97,21 +104,21 @@ def test_version_must_be_semver():
|
|
|
97
104
|
PluginManifest.model_validate(_minimal_manifest(version="1.0"))
|
|
98
105
|
|
|
99
106
|
|
|
100
|
-
def
|
|
107
|
+
def test_schema_version_must_be_3_0():
|
|
101
108
|
with pytest.raises(ValidationError):
|
|
102
109
|
PluginManifest.model_validate(_minimal_manifest(schema_version="1.1"))
|
|
103
110
|
|
|
104
111
|
|
|
105
|
-
@pytest.mark.parametrize("version", ["1.0", "2.0", "2.1", "2.3"])
|
|
112
|
+
@pytest.mark.parametrize("version", ["1.0", "2.0", "2.1", "2.2", "2.3"])
|
|
106
113
|
def test_other_schema_versions_are_rejected(version):
|
|
107
114
|
with pytest.raises(ValidationError):
|
|
108
115
|
PluginManifest.model_validate(_minimal_manifest(schema_version=version))
|
|
109
116
|
|
|
110
117
|
|
|
111
|
-
def
|
|
118
|
+
def test_schema_3_0_world_provider_accepts_custom_resource_type():
|
|
112
119
|
manifest = PluginManifest.model_validate(
|
|
113
120
|
_minimal_manifest(
|
|
114
|
-
schema_version="
|
|
121
|
+
schema_version="3.0",
|
|
115
122
|
capabilities=[
|
|
116
123
|
{
|
|
117
124
|
"id": "workspace.search",
|
|
@@ -128,7 +135,7 @@ def test_schema_2_2_world_provider_accepts_custom_resource_type():
|
|
|
128
135
|
"exposure": "model",
|
|
129
136
|
"produces": [
|
|
130
137
|
{
|
|
131
|
-
"
|
|
138
|
+
"result_path": ".items",
|
|
132
139
|
"resource_type": "workspace.item",
|
|
133
140
|
"output_name": "items",
|
|
134
141
|
"many": True,
|
|
@@ -159,7 +166,7 @@ def test_schema_2_2_world_provider_accepts_custom_resource_type():
|
|
|
159
166
|
)
|
|
160
167
|
)
|
|
161
168
|
|
|
162
|
-
assert manifest.schema_version == "
|
|
169
|
+
assert manifest.schema_version == "3.0"
|
|
163
170
|
assert manifest.world_providers[0].resource_types[0].id == "workspace.item"
|
|
164
171
|
|
|
165
172
|
|
|
@@ -167,7 +174,7 @@ def test_world_provider_rejects_unknown_capability_reference():
|
|
|
167
174
|
with pytest.raises(ValidationError, match="unknown capability"):
|
|
168
175
|
PluginManifest.model_validate(
|
|
169
176
|
_minimal_manifest(
|
|
170
|
-
schema_version="
|
|
177
|
+
schema_version="3.0",
|
|
171
178
|
world_providers=[
|
|
172
179
|
{
|
|
173
180
|
"id": "workspace",
|
|
@@ -175,6 +182,12 @@ def test_world_provider_rejects_unknown_capability_reference():
|
|
|
175
182
|
"description": "Authoritative workspace provider.",
|
|
176
183
|
"connection_id": "test",
|
|
177
184
|
"resource_types": [
|
|
185
|
+
{
|
|
186
|
+
"id": "test.resource",
|
|
187
|
+
"title": "Test resource",
|
|
188
|
+
"description": "One stable test resource.",
|
|
189
|
+
"identity_fields": ["id"],
|
|
190
|
+
},
|
|
178
191
|
{
|
|
179
192
|
"id": "workspace.item",
|
|
180
193
|
"title": "Workspace item",
|
|
@@ -189,6 +202,13 @@ def test_world_provider_rejects_unknown_capability_reference():
|
|
|
189
202
|
)
|
|
190
203
|
|
|
191
204
|
|
|
205
|
+
def test_capability_output_requires_provider_resource_declaration():
|
|
206
|
+
payload = _minimal_manifest()
|
|
207
|
+
payload["capabilities"][0]["produces"][0]["resource_type"] = "missing.resource"
|
|
208
|
+
with pytest.raises(ValidationError, match="not declared by provider"):
|
|
209
|
+
PluginManifest.model_validate(payload)
|
|
210
|
+
|
|
211
|
+
|
|
192
212
|
def test_empty_runtime_types_rejected():
|
|
193
213
|
with pytest.raises(ValidationError):
|
|
194
214
|
PluginManifest.model_validate(_minimal_manifest(runtime_types=[]))
|
|
@@ -60,31 +60,52 @@ def _capabilities():
|
|
|
60
60
|
{"name": "action", "resource_type": "prepared_action"}
|
|
61
61
|
],
|
|
62
62
|
"inputs": [
|
|
63
|
-
{"semantic_field": "recipient", "argument_name": "recipient"},
|
|
64
|
-
{"semantic_field": "body", "argument_name": "body"},
|
|
65
63
|
{
|
|
64
|
+
"kind": "value",
|
|
65
|
+
"semantic_field": "recipient",
|
|
66
|
+
"argument_name": "recipient",
|
|
67
|
+
"sensitivity": "personal",
|
|
68
|
+
"accepted_origins": ["current_user", "world_resource"],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"kind": "value",
|
|
72
|
+
"semantic_field": "body",
|
|
73
|
+
"argument_name": "body",
|
|
74
|
+
"accepted_origins": ["current_user"],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"kind": "value",
|
|
66
78
|
"semantic_field": "subject",
|
|
67
79
|
"argument_name": "subject",
|
|
68
80
|
"required": False,
|
|
81
|
+
"accepted_origins": ["current_user", "world_resource"],
|
|
69
82
|
},
|
|
70
83
|
{
|
|
84
|
+
"kind": "value",
|
|
71
85
|
"semantic_field": "reply_target",
|
|
72
86
|
"argument_name": "reply_to_message_id",
|
|
73
87
|
"required": False,
|
|
88
|
+
"accepted_origins": ["world_resource"],
|
|
74
89
|
},
|
|
75
90
|
{
|
|
91
|
+
"kind": "value",
|
|
76
92
|
"semantic_field": "source_ref",
|
|
77
93
|
"argument_name": "source_email_id",
|
|
78
94
|
"required": False,
|
|
95
|
+
"accepted_origins": ["world_resource"],
|
|
79
96
|
},
|
|
80
97
|
{
|
|
98
|
+
"kind": "value",
|
|
81
99
|
"semantic_field": "source_fingerprint",
|
|
82
100
|
"argument_name": "source_fingerprint",
|
|
83
101
|
"required": False,
|
|
102
|
+
"accepted_origins": ["world_resource"],
|
|
84
103
|
},
|
|
85
104
|
{
|
|
105
|
+
"kind": "value",
|
|
86
106
|
"semantic_field": "idempotency_key",
|
|
87
107
|
"argument_name": "idempotency_key",
|
|
108
|
+
"accepted_origins": ["current_user"],
|
|
88
109
|
},
|
|
89
110
|
],
|
|
90
111
|
"requirements": {
|
|
@@ -104,7 +125,7 @@ def _capabilities():
|
|
|
104
125
|
|
|
105
126
|
def _manifest(**overrides):
|
|
106
127
|
data = {
|
|
107
|
-
"schema_version": "
|
|
128
|
+
"schema_version": "3.0",
|
|
108
129
|
"id": "email-plugin",
|
|
109
130
|
"name": "Email",
|
|
110
131
|
"version": "1.0.0",
|
|
@@ -262,7 +262,7 @@ wheels = [
|
|
|
262
262
|
|
|
263
263
|
[[package]]
|
|
264
264
|
name = "nutria-plugin"
|
|
265
|
-
version = "0.
|
|
265
|
+
version = "0.3.0"
|
|
266
266
|
source = { editable = "." }
|
|
267
267
|
dependencies = [
|
|
268
268
|
{ name = "cryptography" },
|
|
@@ -281,6 +281,7 @@ dev = [
|
|
|
281
281
|
dev = [
|
|
282
282
|
{ name = "pytest" },
|
|
283
283
|
{ name = "pytest-asyncio" },
|
|
284
|
+
{ name = "ruff" },
|
|
284
285
|
]
|
|
285
286
|
|
|
286
287
|
[package.metadata]
|
|
@@ -298,6 +299,7 @@ provides-extras = ["dev"]
|
|
|
298
299
|
dev = [
|
|
299
300
|
{ name = "pytest", specifier = ">=9.0.2" },
|
|
300
301
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
|
302
|
+
{ name = "ruff", specifier = ">=0.8" },
|
|
301
303
|
]
|
|
302
304
|
|
|
303
305
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|