katalyst-plugin-context 0.1.1a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- katalyst_plugin_context/__init__.py +8 -0
- katalyst_plugin_context/adapters/__init__.py +0 -0
- katalyst_plugin_context/adapters/runtime_graph.py +126 -0
- katalyst_plugin_context/cli.py +139 -0
- katalyst_plugin_context/composition.py +49 -0
- katalyst_plugin_context/domain/__init__.py +0 -0
- katalyst_plugin_context/domain/context.py +762 -0
- katalyst_plugin_context/domain/graph.py +97 -0
- katalyst_plugin_context/domain/markdown.py +201 -0
- katalyst_plugin_context/domain/paths.py +114 -0
- katalyst_plugin_context/domain/policy.py +345 -0
- katalyst_plugin_context/domain/settings.py +92 -0
- katalyst_plugin_context/lint.py +149 -0
- katalyst_plugin_context/mcp_tools.py +132 -0
- katalyst_plugin_context/plugin.py +64 -0
- katalyst_plugin_context/py.typed +0 -0
- katalyst_plugin_context/rest.py +133 -0
- katalyst_plugin_context/schema.py +200 -0
- katalyst_plugin_context/service.py +319 -0
- katalyst_plugin_context-0.1.1a1.dist-info/METADATA +13 -0
- katalyst_plugin_context-0.1.1a1.dist-info/RECORD +23 -0
- katalyst_plugin_context-0.1.1a1.dist-info/WHEEL +4 -0
- katalyst_plugin_context-0.1.1a1.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# pyright: reportUnusedFunction=false
|
|
2
|
+
# pyright: reportMissingTypeArgument=false, reportUnknownParameterType=false
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
4
|
+
# pyright: reportArgumentType=false
|
|
5
|
+
# The MCP SDK's Context is generic over types FastMCP never fills in, so every tool
|
|
6
|
+
# signature is partially unknown by construction (as the harness plugin's tools).
|
|
7
|
+
"""taxonomy_context_map, taxonomy_context — what an agent working on a node is given."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
from katalyst_taxonomy_mcp.response import err, guard_backend_errors, ok
|
|
14
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
15
|
+
|
|
16
|
+
from katalyst_plugin_context.composition import service_for
|
|
17
|
+
from katalyst_plugin_context.domain.context import ContextError, ContextRequest
|
|
18
|
+
from katalyst_plugin_context.service import ContextService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _service(ctx: Context, backend: str | None) -> ContextService:
|
|
22
|
+
from katalyst_taxonomy_mcp.server import AppContext
|
|
23
|
+
|
|
24
|
+
app = cast("AppContext", ctx.request_context.lifespan_context)
|
|
25
|
+
return service_for(app.get_registry(), app.read_sources(backend), profile=app.backends.profile)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _refusal(exc: ContextError) -> dict[str, Any]:
|
|
29
|
+
return err(exc.code, exc.message, exc.details)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_context_tools(mcp: FastMCP) -> None:
|
|
33
|
+
"""Register the context tools on the MCP server."""
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
@guard_backend_errors
|
|
37
|
+
def taxonomy_context_map(
|
|
38
|
+
node: str,
|
|
39
|
+
format: str = "json",
|
|
40
|
+
backend: str | None = None,
|
|
41
|
+
ctx: Context = None, # pyright: ignore[reportAssignmentType]
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Start here before working on a node: what the taxonomy holds around it.
|
|
44
|
+
|
|
45
|
+
Lists every relationship type around NODE in both directions, with the kinds and
|
|
46
|
+
counts at the other end, and what NODE inherits from its ancestors (its parent
|
|
47
|
+
chain), each marked with where it comes from. Also lists the named views its kind
|
|
48
|
+
declares, and returns ('always') the views it always includes, such as the decisions
|
|
49
|
+
and standards that govern it. 'options' says what you may ask for next: the steps
|
|
50
|
+
you may follow, the maximum depth, detail levels and budget. Then call
|
|
51
|
+
taxonomy_context with only what your task needs.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
node: Qualified name, 'backend:fqtn' (or the FQTN when one backend holds it).
|
|
55
|
+
format: 'json', or 'markdown' for ready-to-read text in 'markdown'.
|
|
56
|
+
backend: Read only this backend. Default: every backend the profile reads.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
service = _service(ctx, backend)
|
|
60
|
+
payload = service.map(node)
|
|
61
|
+
if format == "markdown":
|
|
62
|
+
return ok({"markdown": service.markdown_for_map(payload), "map": payload})
|
|
63
|
+
return ok(payload)
|
|
64
|
+
except ContextError as exc:
|
|
65
|
+
return _refusal(exc)
|
|
66
|
+
|
|
67
|
+
@mcp.tool()
|
|
68
|
+
@guard_backend_errors
|
|
69
|
+
def taxonomy_context(
|
|
70
|
+
node: str,
|
|
71
|
+
paths: list[str] | None = None,
|
|
72
|
+
views: list[str] | None = None,
|
|
73
|
+
depth: int | None = None,
|
|
74
|
+
detail: str | None = None,
|
|
75
|
+
budget: int | None = None,
|
|
76
|
+
inherit: bool | None = None,
|
|
77
|
+
format: str = "json",
|
|
78
|
+
backend: str | None = None,
|
|
79
|
+
ctx: Context = None, # pyright: ignore[reportAssignmentType]
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""The nodes around NODE that your task needs, with why each one is relevant.
|
|
82
|
+
|
|
83
|
+
With no paths or views, returns the views NODE's kind always includes, or the whole
|
|
84
|
+
neighbourhood at depth 1 if it declares none. Otherwise returns exactly what you ask
|
|
85
|
+
for:
|
|
86
|
+
paths steps 'REL[:in|out[:kind,...]]' joined by '/' into a walk, e.g.
|
|
87
|
+
'SOME_REL:out' or 'A_REL:in/B_REL:in'. Take the steps from
|
|
88
|
+
taxonomy_context_map's options.steps.
|
|
89
|
+
views names from taxonomy_context_map's views.
|
|
90
|
+
depth how many times to follow a single step (default 1).
|
|
91
|
+
detail 'refs' (names and why), 'summary' (plus a short description and key
|
|
92
|
+
fields) or 'full' (every field and the document body).
|
|
93
|
+
budget approximate tokens. Lowest-priority items are shortened first, then cut,
|
|
94
|
+
and 'truncated' says what was cut and how to get it.
|
|
95
|
+
inherit also follow paths from NODE's ancestors (default: per policy).
|
|
96
|
+
Policy set by the schema, the node and the profile can only narrow a request. A
|
|
97
|
+
narrowed request is still answered, and 'policy' says what was narrowed. Each item
|
|
98
|
+
says why it is here (the path, and which ancestor it was inherited from) and, when
|
|
99
|
+
the schema says so, whether it is blocking.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
node: Qualified name, 'backend:fqtn' (or the FQTN when one backend holds it).
|
|
103
|
+
paths: Paths to walk.
|
|
104
|
+
views: Views to return.
|
|
105
|
+
depth: Repetitions of a single-step path.
|
|
106
|
+
detail: refs, summary or full.
|
|
107
|
+
budget: Approximate tokens for the items.
|
|
108
|
+
inherit: Also walk from the node's ancestors.
|
|
109
|
+
format: 'json', or 'markdown' for ready-to-read text in 'markdown'.
|
|
110
|
+
backend: Read only this backend. Default: every backend the profile reads.
|
|
111
|
+
"""
|
|
112
|
+
request = ContextRequest(
|
|
113
|
+
paths=tuple(paths) if paths is not None else None,
|
|
114
|
+
views=tuple(views) if views is not None else None,
|
|
115
|
+
depth=depth,
|
|
116
|
+
detail=detail,
|
|
117
|
+
budget=budget,
|
|
118
|
+
inherit=inherit,
|
|
119
|
+
format=format,
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
service = _service(ctx, backend)
|
|
123
|
+
payload = service.query(node, request)
|
|
124
|
+
if format == "markdown":
|
|
125
|
+
return ok(
|
|
126
|
+
{"markdown": service.markdown_for_result(node, payload), "result": payload}
|
|
127
|
+
)
|
|
128
|
+
return ok(payload)
|
|
129
|
+
except ContextError as exc:
|
|
130
|
+
return _refusal(exc)
|
|
131
|
+
|
|
132
|
+
_ = (taxonomy_context_map, taxonomy_context)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Context plugin: what an agent working on a node gets from the taxonomy.
|
|
2
|
+
|
|
3
|
+
Contributes the MCP tools (``taxonomy_context_map``, ``taxonomy_context``), the REST routes
|
|
4
|
+
(``/ext/context``) and the ``context-policy`` lint rule. ``kata tax context`` arrives over
|
|
5
|
+
the ``katalyst_taxonomy.cli`` entry point. It ships no kinds: bundles declare what their
|
|
6
|
+
kinds' context is in ``plugin_settings["context"]``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from katalyst_taxonomy.plugins.protocol import PluginContext, PluginManifest
|
|
15
|
+
|
|
16
|
+
from katalyst_plugin_context.schema import CONFIG_SCHEMA, NODE_SETTINGS_SCHEMA, SETTINGS_SCHEMA
|
|
17
|
+
from katalyst_plugin_context.service import NAMESPACE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _version() -> str:
|
|
21
|
+
try:
|
|
22
|
+
return version("katalyst-plugin-context")
|
|
23
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
24
|
+
return ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContextPlugin:
|
|
28
|
+
"""The right context for an agent working on a node, over MCP, REST and the CLI."""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
return NAMESPACE
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def manifest(self) -> PluginManifest:
|
|
36
|
+
return PluginManifest(
|
|
37
|
+
name=NAMESPACE,
|
|
38
|
+
version=_version(),
|
|
39
|
+
description=(
|
|
40
|
+
"Give an agent working on a node the right context: the relationships "
|
|
41
|
+
"around it, what it inherits, and the views its kind declares, narrowed "
|
|
42
|
+
"by schema, node and profile policy"
|
|
43
|
+
),
|
|
44
|
+
capabilities=("context", "agents"),
|
|
45
|
+
config_schema=CONFIG_SCHEMA,
|
|
46
|
+
settings_schema=SETTINGS_SCHEMA,
|
|
47
|
+
node_settings_schema=NODE_SETTINGS_SCHEMA,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def register(self, context: PluginContext) -> None:
|
|
51
|
+
from katalyst_plugin_context.lint import ContextPolicyRule
|
|
52
|
+
from katalyst_plugin_context.rest import ContextRestRoutes
|
|
53
|
+
|
|
54
|
+
context.lint_rules.append(ContextPolicyRule(context.schema_registry))
|
|
55
|
+
context.rest_routes.append(ContextRestRoutes())
|
|
56
|
+
|
|
57
|
+
def _register_mcp(mcp: Any) -> None:
|
|
58
|
+
# Imported only when an MCP server calls this, so a host that never serves
|
|
59
|
+
# MCP does not need the MCP SDK just to load plugins.
|
|
60
|
+
from katalyst_plugin_context.mcp_tools import register_context_tools
|
|
61
|
+
|
|
62
|
+
register_context_tools(mcp)
|
|
63
|
+
|
|
64
|
+
context.mcp_tools.append(_register_mcp)
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# pyright: reportUnusedFunction=false
|
|
2
|
+
#
|
|
3
|
+
# The routes are nested functions registered by their decorators and never referenced by
|
|
4
|
+
# name; the REST app mounts the router this returns.
|
|
5
|
+
"""``/ext/context`` — the context map, a context query and the effective policy over REST.
|
|
6
|
+
|
|
7
|
+
============================== ==========================================================
|
|
8
|
+
Route Answers
|
|
9
|
+
============================== ==========================================================
|
|
10
|
+
``GET /ext/context/map`` the map around ``node`` (``format=markdown`` adds text)
|
|
11
|
+
``POST /ext/context/query`` the items a request asks for (``paths``, ``views``, ...)
|
|
12
|
+
``GET /ext/context/policy`` the effective policy for ``node``: layers, limits, notes
|
|
13
|
+
============================== ==========================================================
|
|
14
|
+
|
|
15
|
+
Every route only reads. Each needs the API's bearer token, as every route does.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# No `from __future__ import annotations`: FastAPI reads the handlers' annotations, and
|
|
19
|
+
# the request model is local to router(), so they must be evaluated where it is in scope.
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContextRestRoutes:
|
|
24
|
+
"""Implements :class:`~katalyst_taxonomy.plugins.extensions.RestRoutes`."""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
return "context"
|
|
29
|
+
|
|
30
|
+
def router(self) -> Any:
|
|
31
|
+
from fastapi import APIRouter, Request
|
|
32
|
+
from fastapi.responses import JSONResponse
|
|
33
|
+
from katalyst_engine.schema.registry import SchemaRegistry
|
|
34
|
+
from katalyst_taxonomy.config.routing import BackendAddressError
|
|
35
|
+
from katalyst_taxonomy.engine.bridge import get_default_registry
|
|
36
|
+
from katalyst_taxonomy_mcp.response import ErrorCode, backend_failure, err, ok
|
|
37
|
+
from pydantic import BaseModel, Field
|
|
38
|
+
|
|
39
|
+
from katalyst_plugin_context.composition import service_for
|
|
40
|
+
from katalyst_plugin_context.domain.context import ContextError, ContextRequest
|
|
41
|
+
from katalyst_plugin_context.service import ContextService
|
|
42
|
+
|
|
43
|
+
router = APIRouter()
|
|
44
|
+
status_of = {"NOT_FOUND": 404, "INVALID_INPUT": 422, "VALIDATION_ERROR": 422}
|
|
45
|
+
|
|
46
|
+
class QueryBody(BaseModel):
|
|
47
|
+
node: str = Field(description="Qualified name, 'backend:fqtn'.")
|
|
48
|
+
paths: list[str] | None = None
|
|
49
|
+
views: list[str] | None = None
|
|
50
|
+
depth: int | None = None
|
|
51
|
+
detail: str | None = None
|
|
52
|
+
budget: int | None = None
|
|
53
|
+
inherit: bool | None = None
|
|
54
|
+
format: str = "json"
|
|
55
|
+
backend: str | None = None
|
|
56
|
+
|
|
57
|
+
def service(request: Request, backend: str | None) -> ContextService | JSONResponse:
|
|
58
|
+
registry = request.app.state.backend_registry
|
|
59
|
+
if registry is None:
|
|
60
|
+
reason = request.app.state.backend_error or "No backend is configured."
|
|
61
|
+
return JSONResponse(
|
|
62
|
+
status_code=409,
|
|
63
|
+
content=err(ErrorCode.NO_BACKEND_CONFIGURED, reason, {"configured": False}),
|
|
64
|
+
)
|
|
65
|
+
schema: SchemaRegistry | None = getattr(request.app.state, "schema_registry", None)
|
|
66
|
+
try:
|
|
67
|
+
sources = registry.read_set(backend)
|
|
68
|
+
except BackendAddressError as exc:
|
|
69
|
+
return JSONResponse(status_code=422, content=backend_failure(exc))
|
|
70
|
+
try:
|
|
71
|
+
return service_for(
|
|
72
|
+
schema or get_default_registry(), sources, profile=registry.profile
|
|
73
|
+
)
|
|
74
|
+
except ContextError as exc:
|
|
75
|
+
return refuse(exc)
|
|
76
|
+
|
|
77
|
+
def refuse(exc: ContextError) -> JSONResponse:
|
|
78
|
+
return JSONResponse(
|
|
79
|
+
status_code=status_of.get(exc.code, 422),
|
|
80
|
+
content=err(exc.code, exc.message, exc.details),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@router.get("/map", response_model=None)
|
|
84
|
+
def get_map(
|
|
85
|
+
request: Request, node: str, format: str = "json", backend: str | None = None
|
|
86
|
+
) -> dict[str, Any] | JSONResponse:
|
|
87
|
+
found = service(request, backend)
|
|
88
|
+
if isinstance(found, JSONResponse):
|
|
89
|
+
return found
|
|
90
|
+
try:
|
|
91
|
+
payload = found.map(node)
|
|
92
|
+
except ContextError as exc:
|
|
93
|
+
return refuse(exc)
|
|
94
|
+
if format == "markdown":
|
|
95
|
+
return ok({"markdown": found.markdown_for_map(payload), "map": payload})
|
|
96
|
+
return ok(payload)
|
|
97
|
+
|
|
98
|
+
@router.post("/query", response_model=None)
|
|
99
|
+
def post_query(request: Request, body: QueryBody) -> dict[str, Any] | JSONResponse:
|
|
100
|
+
found = service(request, body.backend)
|
|
101
|
+
if isinstance(found, JSONResponse):
|
|
102
|
+
return found
|
|
103
|
+
asked = ContextRequest(
|
|
104
|
+
paths=tuple(body.paths) if body.paths is not None else None,
|
|
105
|
+
views=tuple(body.views) if body.views is not None else None,
|
|
106
|
+
depth=body.depth,
|
|
107
|
+
detail=body.detail,
|
|
108
|
+
budget=body.budget,
|
|
109
|
+
inherit=body.inherit,
|
|
110
|
+
format=body.format,
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
payload = found.query(body.node, asked)
|
|
114
|
+
if body.format == "markdown":
|
|
115
|
+
markdown = found.markdown_for_result(body.node, payload)
|
|
116
|
+
return ok({"markdown": markdown, "result": payload})
|
|
117
|
+
except ContextError as exc:
|
|
118
|
+
return refuse(exc)
|
|
119
|
+
return ok(payload)
|
|
120
|
+
|
|
121
|
+
@router.get("/policy", response_model=None)
|
|
122
|
+
def get_policy(
|
|
123
|
+
request: Request, node: str, backend: str | None = None
|
|
124
|
+
) -> dict[str, Any] | JSONResponse:
|
|
125
|
+
found = service(request, backend)
|
|
126
|
+
if isinstance(found, JSONResponse):
|
|
127
|
+
return found
|
|
128
|
+
try:
|
|
129
|
+
return ok(found.explain(node))
|
|
130
|
+
except ContextError as exc:
|
|
131
|
+
return refuse(exc)
|
|
132
|
+
|
|
133
|
+
return router
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The JSON Schemas the context plugin publishes for its three kinds of settings.
|
|
2
|
+
|
|
3
|
+
- :data:`CONFIG_SCHEMA`: its ``spec.plugins[].config`` in config.yaml (``PluginManifest.config_schema``);
|
|
4
|
+
- :data:`SETTINGS_SCHEMA`: its ``plugin_settings["context"]`` section on a wing or kind;
|
|
5
|
+
- :data:`NODE_SETTINGS_SCHEMA`: a node's ``spec.extensions.context``.
|
|
6
|
+
|
|
7
|
+
``kata tax lint`` checks bundle and node sections against the last two. A config save checks
|
|
8
|
+
the first. The plugin checks all three again on every call, and a section that fails is
|
|
9
|
+
refused rather than ignored, because ignoring a narrowing would widen what an agent sees.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from katalyst_plugin_context.domain.context import ContextError
|
|
18
|
+
from katalyst_plugin_context.domain.paths import STEP_RE
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CONFIG_SCHEMA",
|
|
22
|
+
"NODE_SETTINGS_SCHEMA",
|
|
23
|
+
"SETTINGS_SCHEMA",
|
|
24
|
+
"check",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_DRAFT = "https://json-schema.org/draft/2020-12/schema"
|
|
28
|
+
_DETAIL = {"enum": ["refs", "summary", "full"]}
|
|
29
|
+
_STEP = {"type": "string", "pattern": f"^{STEP_RE}$"}
|
|
30
|
+
_PATH = {"type": "string", "pattern": f"^{STEP_RE}(/{STEP_RE})*$"}
|
|
31
|
+
_NAME = "^[a-z][a-z0-9-]*$"
|
|
32
|
+
|
|
33
|
+
_POLICY: dict[str, Any] = {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"title": "Policy",
|
|
36
|
+
"description": "Limits that can only narrow what an agent is given.",
|
|
37
|
+
"additionalProperties": False,
|
|
38
|
+
"properties": {
|
|
39
|
+
"paths": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": False,
|
|
42
|
+
"properties": {
|
|
43
|
+
"allow": {
|
|
44
|
+
"type": "array",
|
|
45
|
+
"items": _STEP,
|
|
46
|
+
"description": "Only steps these patterns match are visible.",
|
|
47
|
+
},
|
|
48
|
+
"deny": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": _STEP,
|
|
51
|
+
"description": "Steps these patterns match are hidden.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"max_depth": {"type": "integer", "minimum": 0, "description": "Most hops from the node."},
|
|
56
|
+
"detail": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"items": _DETAIL,
|
|
59
|
+
"description": "Detail levels allowed (refs is always allowed).",
|
|
60
|
+
},
|
|
61
|
+
"budget": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"additionalProperties": False,
|
|
64
|
+
"properties": {"max": {"type": "integer", "minimum": 1}},
|
|
65
|
+
},
|
|
66
|
+
"inheritance": {"type": "boolean", "description": "Whether anything may be inherited."},
|
|
67
|
+
"always_include": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"items": {"type": "string", "pattern": _NAME},
|
|
70
|
+
"description": "Views returned without being asked; only declared views count.",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_DEFAULTS: dict[str, Any] = {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"title": "Defaults",
|
|
78
|
+
"description": "Used when an agent does not say; clamped into the policy.",
|
|
79
|
+
"additionalProperties": False,
|
|
80
|
+
"properties": {
|
|
81
|
+
"depth": {"type": "integer", "minimum": 0},
|
|
82
|
+
"detail": _DETAIL,
|
|
83
|
+
"budget": {"type": "integer", "minimum": 1},
|
|
84
|
+
"inherit": {"type": "boolean"},
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_VIEW: dict[str, Any] = {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"additionalProperties": False,
|
|
91
|
+
"required": ["paths"],
|
|
92
|
+
"properties": {
|
|
93
|
+
"title": {"type": "string"},
|
|
94
|
+
"description": {"type": "string"},
|
|
95
|
+
"paths": {"type": "array", "items": _PATH, "minItems": 1},
|
|
96
|
+
"inherit": {"type": "boolean"},
|
|
97
|
+
"detail": _DETAIL,
|
|
98
|
+
"priority": {"type": "integer"},
|
|
99
|
+
"order": {"enum": ["blocking-first"]},
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_RULE: dict[str, Any] = {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"additionalProperties": False,
|
|
106
|
+
"properties": {
|
|
107
|
+
"match": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"additionalProperties": False,
|
|
110
|
+
"properties": {
|
|
111
|
+
"backend": {"type": "array", "items": {"type": "string"}},
|
|
112
|
+
"kind": {"type": "array", "items": {"type": "string"}},
|
|
113
|
+
"wing": {"type": "array", "items": {"type": "string"}},
|
|
114
|
+
"fqtn": {"type": "array", "items": {"type": "string"}},
|
|
115
|
+
"name": {"type": "array", "items": {"type": "string"}},
|
|
116
|
+
"labels": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
"policy": _POLICY,
|
|
120
|
+
"defaults": _DEFAULTS,
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
CONFIG_SCHEMA: dict[str, Any] = {
|
|
125
|
+
"$schema": _DRAFT,
|
|
126
|
+
"type": "object",
|
|
127
|
+
"title": "Context",
|
|
128
|
+
"additionalProperties": False,
|
|
129
|
+
"properties": {
|
|
130
|
+
"policy": _POLICY,
|
|
131
|
+
"defaults": _DEFAULTS,
|
|
132
|
+
"profiles": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"description": "Stricter policy for one profile, by profile name.",
|
|
135
|
+
"additionalProperties": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"additionalProperties": False,
|
|
138
|
+
"properties": {
|
|
139
|
+
"policy": _POLICY,
|
|
140
|
+
"defaults": _DEFAULTS,
|
|
141
|
+
"rules": {"type": "array", "items": _RULE},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
SETTINGS_SCHEMA: dict[str, Any] = {
|
|
149
|
+
"$schema": _DRAFT,
|
|
150
|
+
"type": "object",
|
|
151
|
+
"additionalProperties": False,
|
|
152
|
+
"properties": {
|
|
153
|
+
"title_field": {"type": "string", "description": "Spec field holding an item's title."},
|
|
154
|
+
"summary_fields": {"type": "array", "items": {"type": "string"}},
|
|
155
|
+
"priority": {"type": "integer"},
|
|
156
|
+
"views": {
|
|
157
|
+
"type": "object",
|
|
158
|
+
"propertyNames": {"pattern": _NAME},
|
|
159
|
+
"additionalProperties": _VIEW,
|
|
160
|
+
},
|
|
161
|
+
"policy": _POLICY,
|
|
162
|
+
"defaults": _DEFAULTS,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
NODE_SETTINGS_SCHEMA: dict[str, Any] = {
|
|
167
|
+
"$schema": _DRAFT,
|
|
168
|
+
"type": "object",
|
|
169
|
+
"additionalProperties": False,
|
|
170
|
+
"properties": {"policy": _POLICY, "defaults": _DEFAULTS},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _location(parts: list[object]) -> str:
|
|
175
|
+
return "".join(f"[{p}]" if isinstance(p, int) else f".{p}" for p in parts)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check(schema: Mapping[str, Any], instance: object, *, where: str) -> None:
|
|
179
|
+
"""Refuse *instance* when *schema* does not accept it.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ContextError: ``VALIDATION_ERROR``, naming *where* and the first problem's key.
|
|
183
|
+
"""
|
|
184
|
+
from jsonschema import validators
|
|
185
|
+
|
|
186
|
+
validator_class = cast("Any", validators.validator_for(dict(schema)))
|
|
187
|
+
errors = sorted(
|
|
188
|
+
validator_class(dict(schema)).iter_errors(instance),
|
|
189
|
+
key=lambda e: [str(p) for p in e.absolute_path],
|
|
190
|
+
)
|
|
191
|
+
if errors:
|
|
192
|
+
first = errors[0]
|
|
193
|
+
raise ContextError(
|
|
194
|
+
"VALIDATION_ERROR",
|
|
195
|
+
f"{where}{_location(list(first.absolute_path))}: {first.message}",
|
|
196
|
+
{
|
|
197
|
+
"where": where,
|
|
198
|
+
"problems": [f"{_location(list(e.absolute_path))}: {e.message}" for e in errors],
|
|
199
|
+
},
|
|
200
|
+
)
|