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.
Files changed (50) hide show
  1. kyno/__init__.py +11 -0
  2. kyno/adapters/LICENSE +22 -0
  3. kyno/adapters/__init__.py +7 -0
  4. kyno/adapters/crewai/__init__.py +6 -0
  5. kyno/adapters/crewai/hooks.py +131 -0
  6. kyno/adapters/langgraph/__init__.py +20 -0
  7. kyno/adapters/langgraph/nodes.py +120 -0
  8. kyno/authoring.py +85 -0
  9. kyno/cli.py +299 -0
  10. kyno/config.py +95 -0
  11. kyno/conformance/LICENSE +22 -0
  12. kyno/conformance/__init__.py +78 -0
  13. kyno/conformance/__main__.py +37 -0
  14. kyno/errors.py +54 -0
  15. kyno/mcp_server.py +374 -0
  16. kyno/migrations/__init__.py +0 -0
  17. kyno/migrations/env.py +38 -0
  18. kyno/migrations/script.py.mako +20 -0
  19. kyno/migrations/versions/0001_initial.py +51 -0
  20. kyno/migrations/versions/0002_publication.py +35 -0
  21. kyno/migrations/versions/0003_declaration.py +29 -0
  22. kyno/migrations/versions/__init__.py +0 -0
  23. kyno/models.py +231 -0
  24. kyno/public_page.py +264 -0
  25. kyno/sdk/LICENSE +22 -0
  26. kyno/sdk/__init__.py +128 -0
  27. kyno/sdk/binder.py +70 -0
  28. kyno/sdk/cell.py +145 -0
  29. kyno/sdk/client.py +266 -0
  30. kyno/sdk/gate.py +154 -0
  31. kyno/sdk/plan.py +38 -0
  32. kyno/sdk/policy.py +68 -0
  33. kyno/sdk/subscriber.py +122 -0
  34. kyno/sdk/trace.py +140 -0
  35. kyno/service.py +330 -0
  36. kyno/store/__init__.py +3 -0
  37. kyno/store/base.py +39 -0
  38. kyno/store/schema.py +49 -0
  39. kyno/store/sql.py +254 -0
  40. kyno/templates/constitution.html +19 -0
  41. kyno/templates/index.html +16 -0
  42. kyno/templates/page.css +89 -0
  43. kyno/transports.py +177 -0
  44. kyno-0.1.0.dist-info/METADATA +479 -0
  45. kyno-0.1.0.dist-info/RECORD +50 -0
  46. kyno-0.1.0.dist-info/WHEEL +4 -0
  47. kyno-0.1.0.dist-info/entry_points.txt +2 -0
  48. kyno-0.1.0.dist-info/licenses/LICENSE +26 -0
  49. kyno-0.1.0.dist-info/licenses/LICENSES/Elastic-2.0.txt +93 -0
  50. 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,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """CrewAI shim — install with ``pip install "kyno[crewai]"``."""
3
+
4
+ from kyno.adapters.crewai.hooks import CrewAiKyno
5
+
6
+ __all__ = ["CrewAiKyno"]
@@ -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