kyno 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kyno/__init__.py +11 -0
- kyno/adapters/LICENSE +22 -0
- kyno/adapters/__init__.py +7 -0
- kyno/adapters/crewai/__init__.py +6 -0
- kyno/adapters/crewai/hooks.py +131 -0
- kyno/adapters/langgraph/__init__.py +20 -0
- kyno/adapters/langgraph/nodes.py +120 -0
- kyno/authoring.py +85 -0
- kyno/cli.py +299 -0
- kyno/config.py +95 -0
- kyno/conformance/LICENSE +22 -0
- kyno/conformance/__init__.py +78 -0
- kyno/conformance/__main__.py +37 -0
- kyno/errors.py +54 -0
- kyno/mcp_server.py +374 -0
- kyno/migrations/__init__.py +0 -0
- kyno/migrations/env.py +38 -0
- kyno/migrations/script.py.mako +20 -0
- kyno/migrations/versions/0001_initial.py +51 -0
- kyno/migrations/versions/0002_publication.py +35 -0
- kyno/migrations/versions/0003_declaration.py +29 -0
- kyno/migrations/versions/__init__.py +0 -0
- kyno/models.py +231 -0
- kyno/public_page.py +264 -0
- kyno/sdk/LICENSE +22 -0
- kyno/sdk/__init__.py +128 -0
- kyno/sdk/binder.py +70 -0
- kyno/sdk/cell.py +145 -0
- kyno/sdk/client.py +266 -0
- kyno/sdk/gate.py +154 -0
- kyno/sdk/plan.py +38 -0
- kyno/sdk/policy.py +68 -0
- kyno/sdk/subscriber.py +122 -0
- kyno/sdk/trace.py +140 -0
- kyno/service.py +330 -0
- kyno/store/__init__.py +3 -0
- kyno/store/base.py +39 -0
- kyno/store/schema.py +49 -0
- kyno/store/sql.py +254 -0
- kyno/templates/constitution.html +19 -0
- kyno/templates/index.html +16 -0
- kyno/templates/page.css +89 -0
- kyno/transports.py +177 -0
- kyno-0.1.0.dist-info/METADATA +479 -0
- kyno-0.1.0.dist-info/RECORD +50 -0
- kyno-0.1.0.dist-info/WHEEL +4 -0
- kyno-0.1.0.dist-info/entry_points.txt +2 -0
- kyno-0.1.0.dist-info/licenses/LICENSE +26 -0
- kyno-0.1.0.dist-info/licenses/LICENSES/Elastic-2.0.txt +93 -0
- kyno-0.1.0.dist-info/licenses/LICENSES/MIT.txt +21 -0
kyno/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def __getattr__(name: str):
|
|
5
|
+
# `import kyno` stays cheap for the CLI and the server; the SDK loads on
|
|
6
|
+
# first use of kyno.connect.
|
|
7
|
+
if name == "connect":
|
|
8
|
+
from kyno.sdk import connect
|
|
9
|
+
|
|
10
|
+
return connect
|
|
11
|
+
raise AttributeError(f"module 'kyno' has no attribute {name!r}")
|
kyno/adapters/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Camilo Zambrano
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Adapters that wire the Kyno control plane into agent orchestrators.
|
|
3
|
+
|
|
4
|
+
The framework-agnostic core lives in ``kyno.sdk``; per-framework
|
|
5
|
+
shims live in ``kyno.adapters.crewai`` and ``kyno.adapters.langgraph`` and
|
|
6
|
+
are installed with the ``kyno[crewai]`` / ``kyno[langgraph]`` extras.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from kyno.sdk.binder import DirectionBinder
|
|
7
|
+
from kyno.sdk.cell import Direction, refresh
|
|
8
|
+
from kyno.sdk.gate import RealignmentGate
|
|
9
|
+
from kyno.sdk.trace import RunTrace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskBlockedByKyno(RuntimeError):
|
|
13
|
+
"""Raised from task_callback when the gate rules DRIFTED.
|
|
14
|
+
|
|
15
|
+
task_callback has no return-based block signal the way before_llm_call /
|
|
16
|
+
after_llm_call do, so raising is the only lever at this granularity --
|
|
17
|
+
the exception propagates through Crew.kickoff() like any other.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, reason: str, *, direction: Direction) -> None:
|
|
21
|
+
super().__init__(
|
|
22
|
+
f"kyno gate blocked task: {reason} "
|
|
23
|
+
f"(constitution={direction.constitution}, version={direction.version})"
|
|
24
|
+
)
|
|
25
|
+
self.reason = reason
|
|
26
|
+
self.direction = direction
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CrewAiKyno:
|
|
30
|
+
"""Direction injection (before_llm_call) and the gate (task_callback) for CrewAI.
|
|
31
|
+
|
|
32
|
+
Gating is per finished task, not per LLM call: a task can involve
|
|
33
|
+
several model calls, and once a real judge is attached, gating each one
|
|
34
|
+
multiplies its cost by every call instead of charging it once.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
binder: DirectionBinder,
|
|
40
|
+
gate: RealignmentGate | None = None,
|
|
41
|
+
constitution: str = "default",
|
|
42
|
+
trace: RunTrace | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._binder = binder
|
|
45
|
+
# No gate unless one is handed in. Kyno carries direction; checking the
|
|
46
|
+
# work against it needs a judge Kyno does not ship, so a default gate
|
|
47
|
+
# would only ever answer "unchecked".
|
|
48
|
+
self.gate = gate
|
|
49
|
+
self.constitution = constitution
|
|
50
|
+
self.trace = trace
|
|
51
|
+
|
|
52
|
+
def before_llm_call(self, ctx: Any) -> None:
|
|
53
|
+
direction = self._binder.bind(self.constitution)
|
|
54
|
+
messages = getattr(ctx, "messages", None)
|
|
55
|
+
if messages is None:
|
|
56
|
+
messages = ctx.messages = []
|
|
57
|
+
# CrewAI's executor keeps a reference to this list, so rebinding it
|
|
58
|
+
# would silently detach the hook from the call it is editing.
|
|
59
|
+
messages[:] = refresh(
|
|
60
|
+
messages,
|
|
61
|
+
direction.render(),
|
|
62
|
+
text_of=_system_content,
|
|
63
|
+
make=lambda block: {"role": "system", "content": block},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def task_callback(self, task_output: Any) -> None:
|
|
67
|
+
direction = self._binder.cell.get(self.constitution) or Direction.empty(self.constitution)
|
|
68
|
+
output = _output_text(task_output)
|
|
69
|
+
decision = self.gate.review(output=output, direction=direction) if self.gate else None
|
|
70
|
+
if self.trace is not None:
|
|
71
|
+
self.trace.record_step(
|
|
72
|
+
agent=_name_of(getattr(task_output, "agent", "")),
|
|
73
|
+
goal=str(getattr(task_output, "description", "")),
|
|
74
|
+
output=output,
|
|
75
|
+
direction=direction,
|
|
76
|
+
decision=decision,
|
|
77
|
+
)
|
|
78
|
+
if decision is not None and decision.halts(can_pause=False):
|
|
79
|
+
# CrewAI cannot resume a paused task, so a pause behaves like a
|
|
80
|
+
# block here.
|
|
81
|
+
raise TaskBlockedByKyno(decision.reason, direction=direction)
|
|
82
|
+
|
|
83
|
+
def step_callback(self, step_output: Any) -> None:
|
|
84
|
+
if self.trace is None:
|
|
85
|
+
return
|
|
86
|
+
direction = self._binder.cell.get(self.constitution) or Direction.empty(self.constitution)
|
|
87
|
+
self.trace.record_step(
|
|
88
|
+
agent=_name_of(getattr(step_output, "agent", "")),
|
|
89
|
+
goal=_name_of(getattr(step_output, "task", "")),
|
|
90
|
+
output=str(getattr(step_output, "output", step_output)),
|
|
91
|
+
direction=direction,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def register(self) -> None:
|
|
95
|
+
"""Only before_llm_call is globally registrable; the callbacks below
|
|
96
|
+
are Crew constructor parameters. The decorator form stamps an
|
|
97
|
+
attribute on what it is handed and so rejects a bound method."""
|
|
98
|
+
from crewai.hooks import register_before_llm_call_hook
|
|
99
|
+
|
|
100
|
+
register_before_llm_call_hook(self.before_llm_call)
|
|
101
|
+
|
|
102
|
+
def unregister(self) -> bool:
|
|
103
|
+
from crewai.hooks import unregister_before_llm_call_hook
|
|
104
|
+
|
|
105
|
+
return unregister_before_llm_call_hook(self.before_llm_call)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _system_content(message: Any) -> str:
|
|
109
|
+
# Only a system message can be the block this adapter injected; marker
|
|
110
|
+
# text on any other role is data (a tool result echoed into the
|
|
111
|
+
# transcript, a user paste) and is never the adapter's to delete.
|
|
112
|
+
if not isinstance(message, dict) or message.get("role") != "system":
|
|
113
|
+
return ""
|
|
114
|
+
content = message.get("content", "")
|
|
115
|
+
return content if isinstance(content, str) else ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _output_text(ctx: Any) -> str:
|
|
119
|
+
for attribute in ("raw", "response", "payload", "output"):
|
|
120
|
+
value = getattr(ctx, attribute, None)
|
|
121
|
+
if value:
|
|
122
|
+
return str(value)
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _name_of(value: Any) -> str:
|
|
127
|
+
for attribute in ("role", "name", "description"):
|
|
128
|
+
found = getattr(value, attribute, None)
|
|
129
|
+
if found:
|
|
130
|
+
return str(found)
|
|
131
|
+
return str(value)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""LangGraph shim — install with ``pip install "kyno[langgraph]"``."""
|
|
3
|
+
|
|
4
|
+
from kyno.adapters.langgraph.nodes import (
|
|
5
|
+
KynoState,
|
|
6
|
+
direction_from_state,
|
|
7
|
+
direction_node,
|
|
8
|
+
direction_update,
|
|
9
|
+
gate_node,
|
|
10
|
+
pull_before,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"KynoState",
|
|
15
|
+
"direction_from_state",
|
|
16
|
+
"direction_node",
|
|
17
|
+
"direction_update",
|
|
18
|
+
"gate_node",
|
|
19
|
+
"pull_before",
|
|
20
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypedDict
|
|
7
|
+
|
|
8
|
+
from langgraph.types import interrupt
|
|
9
|
+
|
|
10
|
+
from kyno.sdk.binder import DirectionBinder
|
|
11
|
+
from kyno.sdk.cell import COMPACT, Direction
|
|
12
|
+
from kyno.sdk.gate import Action, RealignmentGate
|
|
13
|
+
from kyno.sdk.trace import RunTrace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KynoState(TypedDict, total=False):
|
|
17
|
+
"""Inherit this in your graph's state schema. LangGraph only carries the
|
|
18
|
+
keys a schema declares, so without it the direction a node pulls never
|
|
19
|
+
reaches the gate node that must judge against it -- silently.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
kyno_constitution: str
|
|
23
|
+
kyno_version: int
|
|
24
|
+
kyno_mission: str
|
|
25
|
+
kyno_principles: list[dict]
|
|
26
|
+
kyno_context: str
|
|
27
|
+
kyno_direction: str
|
|
28
|
+
kyno_verdict: str
|
|
29
|
+
kyno_checked: bool
|
|
30
|
+
kyno_blocked: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def direction_update(direction: Direction) -> dict:
|
|
34
|
+
"""Direction travels in graph state so a persisted checkpoint says which
|
|
35
|
+
constitution and version a step served, without any other context."""
|
|
36
|
+
return {
|
|
37
|
+
"kyno_constitution": direction.constitution,
|
|
38
|
+
"kyno_version": direction.version,
|
|
39
|
+
"kyno_mission": direction.mission,
|
|
40
|
+
"kyno_principles": [p.to_dict() for p in direction.principles],
|
|
41
|
+
"kyno_direction": direction.render(),
|
|
42
|
+
"kyno_context": direction.context,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def direction_from_state(state: dict) -> Direction:
|
|
47
|
+
return Direction(
|
|
48
|
+
constitution=state.get("kyno_constitution", "default"),
|
|
49
|
+
version=state.get("kyno_version", 0),
|
|
50
|
+
mission=state.get("kyno_mission", ""),
|
|
51
|
+
principles=state.get("kyno_principles", ()),
|
|
52
|
+
context=state.get("kyno_context", COMPACT),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def direction_node(binder: DirectionBinder, constitution: str = "default") -> Callable:
|
|
57
|
+
"""A sentinel node, and the `pre_model_hook` for prebuilt ReAct agents:
|
|
58
|
+
one refresh ahead of a fan-out serves every node downstream."""
|
|
59
|
+
|
|
60
|
+
def node(state: dict) -> dict:
|
|
61
|
+
return direction_update(binder.bind(constitution))
|
|
62
|
+
|
|
63
|
+
return node
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def pull_before(binder: DirectionBinder, constitution: str = "default") -> Callable:
|
|
67
|
+
def decorator(node: Callable) -> Callable:
|
|
68
|
+
@functools.wraps(node)
|
|
69
|
+
def wrapped(state: dict, *args: Any, **kwargs: Any) -> dict:
|
|
70
|
+
direction = binder.bind(constitution)
|
|
71
|
+
update = direction_update(direction)
|
|
72
|
+
result = node({**state, **update}, *args, **kwargs) or {}
|
|
73
|
+
return {**update, **result}
|
|
74
|
+
|
|
75
|
+
return wrapped
|
|
76
|
+
|
|
77
|
+
return decorator
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def gate_node(
|
|
81
|
+
gate: RealignmentGate,
|
|
82
|
+
output_key: str = "output",
|
|
83
|
+
trace: RunTrace | None = None,
|
|
84
|
+
) -> Callable:
|
|
85
|
+
"""The gate as a node. It judges against the direction already in state --
|
|
86
|
+
binding is direction_node's job. On PAUSE it interrupts; LangGraph re-runs the
|
|
87
|
+
node from its start on resume, so everything before the interrupt here
|
|
88
|
+
is idempotent (a review and a record)."""
|
|
89
|
+
|
|
90
|
+
def node(state: dict) -> dict:
|
|
91
|
+
direction = direction_from_state(state)
|
|
92
|
+
output = str(state.get(output_key, ""))
|
|
93
|
+
decision = gate.review(output=output, direction=direction)
|
|
94
|
+
if trace is not None:
|
|
95
|
+
trace.record_step(
|
|
96
|
+
agent=str(state.get("kyno_agent", "graph")),
|
|
97
|
+
goal=str(state.get("kyno_goal", "")),
|
|
98
|
+
output=output,
|
|
99
|
+
direction=direction,
|
|
100
|
+
decision=decision,
|
|
101
|
+
)
|
|
102
|
+
blocked = decision.halts(can_pause=True)
|
|
103
|
+
if decision.action is Action.PAUSE:
|
|
104
|
+
answer = interrupt(
|
|
105
|
+
{
|
|
106
|
+
"reason": decision.reason,
|
|
107
|
+
"verdict": decision.verdict.value,
|
|
108
|
+
"constitution": decision.constitution,
|
|
109
|
+
"version": decision.version,
|
|
110
|
+
"output": output,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
blocked = not (isinstance(answer, dict) and answer.get("accept") is True)
|
|
114
|
+
return {
|
|
115
|
+
"kyno_verdict": decision.verdict.value,
|
|
116
|
+
"kyno_checked": decision.checked,
|
|
117
|
+
"kyno_blocked": blocked,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return node
|
kyno/authoring.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Reading a constitution out of a file an operator wrote.
|
|
2
|
+
|
|
3
|
+
A declaration is paragraphs and a description is a paragraph; both are
|
|
4
|
+
miserable to type as command-line flags and worse to re-type on the next
|
|
5
|
+
edit. A file is where a rich constitution actually gets written, and it is
|
|
6
|
+
YAML because a block scalar is the readable way to hold prose (JSON parses
|
|
7
|
+
too -- it is valid YAML).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from kyno.errors import AuthoringError, CoherenceError
|
|
17
|
+
from kyno.models import Principle, normalize_principles
|
|
18
|
+
|
|
19
|
+
# Every key a file may carry. An unknown one is refused rather than ignored:
|
|
20
|
+
# a constitution missing the half somebody thought they wrote is worse than
|
|
21
|
+
# a file that will not load.
|
|
22
|
+
FIELDS = ("constitution", "mission", "declaration", "principles", "note", "by")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ConstitutionFile:
|
|
27
|
+
"""What the file said. None means the key was absent, which set_direction
|
|
28
|
+
reads as "carry this one forward" -- clearing a field is spelled ""."""
|
|
29
|
+
|
|
30
|
+
constitution: str | None = None
|
|
31
|
+
mission: str | None = None
|
|
32
|
+
declaration: str | None = None
|
|
33
|
+
principles: tuple[Principle, ...] | None = None
|
|
34
|
+
note: str | None = None
|
|
35
|
+
created_by: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_constitution_file(path: str) -> ConstitutionFile:
|
|
39
|
+
document = _load(path)
|
|
40
|
+
if not isinstance(document, Mapping):
|
|
41
|
+
raise AuthoringError(f"{path}: a constitution file must be a mapping of fields")
|
|
42
|
+
unknown = sorted(set(document) - set(FIELDS))
|
|
43
|
+
if unknown:
|
|
44
|
+
raise AuthoringError(
|
|
45
|
+
f"{path}: unknown field(s) {', '.join(unknown)} "
|
|
46
|
+
f"(a constitution file takes {', '.join(FIELDS)})"
|
|
47
|
+
)
|
|
48
|
+
return ConstitutionFile(
|
|
49
|
+
constitution=_text(document, "constitution", path),
|
|
50
|
+
mission=_text(document, "mission", path),
|
|
51
|
+
declaration=_text(document, "declaration", path),
|
|
52
|
+
principles=_principles(document, path),
|
|
53
|
+
note=_text(document, "note", path),
|
|
54
|
+
created_by=_text(document, "by", path),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load(path: str):
|
|
59
|
+
import yaml
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
64
|
+
raise AuthoringError(f"{path} could not be read: {exc}") from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _text(document: Mapping, field: str, path: str) -> str | None:
|
|
68
|
+
# A key with nothing after it is far more often a half-written file than
|
|
69
|
+
# a deletion, so it reads as absent; clearing a field is spelled "".
|
|
70
|
+
value = document.get(field)
|
|
71
|
+
if value is None:
|
|
72
|
+
return None
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
raise AuthoringError(f"{path}: {field} must be text, got {type(value).__name__}")
|
|
75
|
+
return value.strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _principles(document: Mapping, path: str) -> tuple[Principle, ...] | None:
|
|
79
|
+
value = document.get("principles")
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return normalize_principles(value)
|
|
84
|
+
except CoherenceError as exc:
|
|
85
|
+
raise AuthoringError(f"{path}: principles: {exc}") from None
|