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.
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/CHANGELOG.md +13 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/PKG-INFO +20 -6
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/README.md +18 -4
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/index.md +3 -2
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/manifest.md +30 -2
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/pyproject.toml +1 -1
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/__init__.py +17 -1
- nutria_plugin-0.2.2/src/nutria_plugin/capabilities.py +350 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/manifest.py +23 -2
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/packaging.py +1 -1
- nutria_plugin-0.2.2/tests/test_capability_contracts.py +97 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_manifest.py +75 -1
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/uv.lock +1 -1
- nutria_plugin-0.2.0/src/nutria_plugin/capabilities.py +0 -142
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/.github/workflows/publish.yml +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/.gitignore +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/admin-extensions.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/admin-flows.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/cli.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/connection-types.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/python-api.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/quickstart.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/reviewable-actions.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/security.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/docs/skill-format.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/examples/my-first-plugin/README.md +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/examples/my-first-plugin/hooks/hooks.json +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/examples/my-first-plugin/plugin.json +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/examples/my-first-plugin/settings.schema.json +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/bundle.py +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/cli.py +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/src/nutria_plugin/signing.py +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_bundle.py +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_cli.py +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_packaging.py +0 -0
- {nutria_plugin-0.2.0 → nutria_plugin-0.2.2}/tests/test_reviewable_actions.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: nutria-plugin
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
25
|
-
`2.
|
|
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.
|
|
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.
|
|
6
|
-
`2.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -15,16 +15,24 @@ Signing:
|
|
|
15
15
|
generate_keypair, sign_manifest, verify_manifest, SignatureStatus
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "0.2.
|
|
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)
|
|
@@ -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
|
|
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=[]))
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|