pykel 1.0.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 (123) hide show
  1. kel/__init__.py +47 -0
  2. kel/agents/__init__.py +14 -0
  3. kel/agents/agent.py +160 -0
  4. kel/agents/events.py +18 -0
  5. kel/agents/orchestration.py +133 -0
  6. kel/agents/tool.py +20 -0
  7. kel/brain/__init__.py +16 -0
  8. kel/brain/brain.py +56 -0
  9. kel/brain/loop_control.py +22 -0
  10. kel/brain/router.py +75 -0
  11. kel/brain/scheduler.py +44 -0
  12. kel/brain/types.py +12 -0
  13. kel/budget/__init__.py +15 -0
  14. kel/budget/budgeted.py +55 -0
  15. kel/budget/errors.py +11 -0
  16. kel/budget/pricing.py +29 -0
  17. kel/budget/tracker.py +74 -0
  18. kel/budget/types.py +27 -0
  19. kel/caching/__init__.py +11 -0
  20. kel/caching/cache.py +53 -0
  21. kel/caching/cached.py +101 -0
  22. kel/caching/key.py +36 -0
  23. kel/context/__init__.py +18 -0
  24. kel/context/errors.py +19 -0
  25. kel/context/eviction.py +41 -0
  26. kel/context/loop.py +64 -0
  27. kel/context/tokens.py +26 -0
  28. kel/context/window.py +51 -0
  29. kel/heal/__init__.py +15 -0
  30. kel/heal/diagnoser.py +51 -0
  31. kel/heal/errors.py +12 -0
  32. kel/heal/healer.py +74 -0
  33. kel/heal/learning.py +19 -0
  34. kel/heal/types.py +20 -0
  35. kel/memory/__init__.py +18 -0
  36. kel/memory/consolidation.py +29 -0
  37. kel/memory/episodic.py +53 -0
  38. kel/memory/memory.py +33 -0
  39. kel/memory/procedural.py +30 -0
  40. kel/memory/semantic.py +70 -0
  41. kel/memory/working.py +13 -0
  42. kel/models/__init__.py +57 -0
  43. kel/models/base.py +88 -0
  44. kel/models/errors.py +31 -0
  45. kel/models/providers/__init__.py +0 -0
  46. kel/models/providers/anthropic.py +277 -0
  47. kel/models/providers/cohere.py +399 -0
  48. kel/models/providers/gemini.py +347 -0
  49. kel/models/providers/mistral.py +266 -0
  50. kel/models/providers/openai.py +380 -0
  51. kel/models/registry.py +191 -0
  52. kel/models/structured.py +132 -0
  53. kel/models/types.py +126 -0
  54. kel/monitoring/__init__.py +4 -0
  55. kel/monitoring/dashboard.py +139 -0
  56. kel/monitoring/metrics.py +116 -0
  57. kel/observability/__init__.py +17 -0
  58. kel/observability/instrumented.py +59 -0
  59. kel/observability/otel.py +58 -0
  60. kel/observability/sinks.py +43 -0
  61. kel/observability/tracer.py +93 -0
  62. kel/observability/types.py +17 -0
  63. kel/prompting/__init__.py +13 -0
  64. kel/prompting/cot.py +23 -0
  65. kel/prompting/few_shot.py +29 -0
  66. kel/prompting/react.py +30 -0
  67. kel/py.typed +0 -0
  68. kel/ratelimit/__init__.py +4 -0
  69. kel/ratelimit/limiter.py +71 -0
  70. kel/ratelimit/rate_limited.py +43 -0
  71. kel/realtime/__init__.py +9 -0
  72. kel/realtime/dual_path.py +31 -0
  73. kel/realtime/providers.py +26 -0
  74. kel/retrieval/__init__.py +20 -0
  75. kel/retrieval/chroma_store.py +99 -0
  76. kel/retrieval/embeddings.py +47 -0
  77. kel/retrieval/loaders.py +40 -0
  78. kel/retrieval/pgvector_store.py +131 -0
  79. kel/retrieval/pinecone_store.py +110 -0
  80. kel/retrieval/qdrant.py +133 -0
  81. kel/retrieval/retriever.py +73 -0
  82. kel/retrieval/splitter.py +78 -0
  83. kel/retrieval/store.py +71 -0
  84. kel/retrieval/types.py +17 -0
  85. kel/retrieval/weaviate_store.py +123 -0
  86. kel/runtime/__init__.py +16 -0
  87. kel/runtime/checkpoint.py +41 -0
  88. kel/runtime/executor.py +141 -0
  89. kel/runtime/graph.py +57 -0
  90. kel/runtime/interrupt.py +16 -0
  91. kel/sdk/__init__.py +11 -0
  92. kel/sdk/banner.py +44 -0
  93. kel/sdk/build.py +32 -0
  94. kel/sdk/cli.py +91 -0
  95. kel/sdk/serve.py +84 -0
  96. kel/specs/__init__.py +22 -0
  97. kel/specs/eval.py +40 -0
  98. kel/specs/frontmatter.py +21 -0
  99. kel/specs/llm_eval.py +63 -0
  100. kel/specs/loader.py +47 -0
  101. kel/specs/types.py +35 -0
  102. kel/storage/__init__.py +10 -0
  103. kel/storage/artifacts.py +47 -0
  104. kel/storage/blob.py +50 -0
  105. kel/storage/checkpoint_store.py +98 -0
  106. kel/storage/s3.py +58 -0
  107. kel/testing/__init__.py +23 -0
  108. kel/testing/assertions.py +44 -0
  109. kel/testing/cassette.py +29 -0
  110. kel/testing/recording.py +38 -0
  111. kel/testing/replay.py +42 -0
  112. kel/tools/__init__.py +33 -0
  113. kel/tools/code_exec.py +61 -0
  114. kel/tools/search_registry.py +62 -0
  115. kel/tools/shell_tool.py +57 -0
  116. kel/tools/sql_tool.py +68 -0
  117. kel/tools/web_fetch.py +99 -0
  118. kel/tools/web_search.py +340 -0
  119. pykel-1.0.0.dist-info/METADATA +202 -0
  120. pykel-1.0.0.dist-info/RECORD +123 -0
  121. pykel-1.0.0.dist-info/WHEEL +4 -0
  122. pykel-1.0.0.dist-info/entry_points.txt +2 -0
  123. pykel-1.0.0.dist-info/licenses/LICENSE +21 -0
kel/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ from kel.models import (
2
+ ChatModel,
3
+ EmbeddingModel,
4
+ ImagePart,
5
+ Message,
6
+ MessageStop,
7
+ ModelResponse,
8
+ Role,
9
+ StructuredOutputError,
10
+ TextDelta,
11
+ TextPart,
12
+ ToolCallDelta,
13
+ ToolResultPart,
14
+ ToolSpec,
15
+ ToolUsePart,
16
+ Usage,
17
+ agenerate_structured,
18
+ generate_structured,
19
+ get_embedding_model,
20
+ get_model,
21
+ )
22
+ from kel.observability import get_tracer
23
+
24
+ __all__ = [
25
+ "ChatModel",
26
+ "EmbeddingModel",
27
+ "ImagePart",
28
+ "Message",
29
+ "MessageStop",
30
+ "ModelResponse",
31
+ "Role",
32
+ "StructuredOutputError",
33
+ "TextDelta",
34
+ "TextPart",
35
+ "ToolCallDelta",
36
+ "ToolResultPart",
37
+ "ToolSpec",
38
+ "ToolUsePart",
39
+ "Usage",
40
+ "agenerate_structured",
41
+ "generate_structured",
42
+ "get_embedding_model",
43
+ "get_model",
44
+ "get_tracer",
45
+ ]
46
+
47
+ __version__ = "1.0.0"
kel/agents/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from kel.agents.agent import Agent
2
+ from kel.agents.events import ToolResultEvent
3
+ from kel.agents.orchestration import run_parallel, run_supervisor, run_swarm, sequential_pipeline
4
+ from kel.agents.tool import Tool
5
+
6
+ __all__ = [
7
+ "Agent",
8
+ "Tool",
9
+ "ToolResultEvent",
10
+ "run_parallel",
11
+ "run_supervisor",
12
+ "run_swarm",
13
+ "sequential_pipeline",
14
+ ]
kel/agents/agent.py ADDED
@@ -0,0 +1,160 @@
1
+ """Single-agent tool-calling loop, built directly on kel.context.Loop
2
+ (step budget + stuck-loop detection) and kel.memory.Memory (working +
3
+ episodic). This is the building block kel.agents' multi-agent orchestration
4
+ patterns compose — a "multi-agent framework" that can't run one agent
5
+ well isn't worth having a supervisor pattern on top of."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import AsyncIterator, Callable, Iterator
11
+ from typing import Any
12
+
13
+ from kel.agents.events import ToolResultEvent
14
+ from kel.agents.tool import Tool
15
+ from kel.context.loop import Loop
16
+ from kel.memory.memory import Memory
17
+ from kel.models.base import ChatModel
18
+ from kel.models.types import Message, MessageStop, ModelResponse, Role, StreamEvent, ToolResultPart
19
+
20
+
21
+ class Agent:
22
+ def __init__(
23
+ self,
24
+ name: str,
25
+ model: ChatModel,
26
+ *,
27
+ system_prompt: str | None = None,
28
+ tools: list[Tool] | None = None,
29
+ memory: Memory | None = None,
30
+ loop_factory: Callable[[], Loop] | None = None,
31
+ ):
32
+ self.name = name
33
+ self.model = model
34
+ self.system_prompt = system_prompt
35
+ self.tools = {t.name: t for t in (tools or [])}
36
+ self.memory = memory or Memory(session_id=name)
37
+ self._loop_factory = loop_factory or (lambda: Loop(max_iterations=10))
38
+
39
+ def run(self, user_input: str) -> ModelResponse:
40
+ self.memory.remember_turn(Message.user(user_input))
41
+ loop = self._loop_factory()
42
+ tool_specs = [t.to_spec() for t in self.tools.values()] or None
43
+
44
+ while True:
45
+ loop.step()
46
+ response = self.model.generate(
47
+ self.memory.working.messages, system=self.system_prompt, tools=tool_specs
48
+ )
49
+ self.memory.remember_turn(Message(role=Role.ASSISTANT, content=response.content))
50
+
51
+ if response.stop_reason != "tool_use":
52
+ return response
53
+
54
+ result_parts: list[Any] = []
55
+ for call in response.tool_calls:
56
+ signature = f"{call.name}:{json.dumps(call.input, sort_keys=True)}"
57
+ loop.record_action(signature)
58
+ result_parts.append(self._execute_tool_call(call.id, call.name, call.input))
59
+
60
+ self.memory.remember_turn(Message(role=Role.USER, content=result_parts))
61
+
62
+ async def arun(self, user_input: str) -> ModelResponse:
63
+ """Async equivalent of `run()` — same loop, uses the model's real
64
+ `agenerate()` instead of blocking `generate()`."""
65
+ self.memory.remember_turn(Message.user(user_input))
66
+ loop = self._loop_factory()
67
+ tool_specs = [t.to_spec() for t in self.tools.values()] or None
68
+
69
+ while True:
70
+ loop.step()
71
+ response = await self.model.agenerate(
72
+ self.memory.working.messages, system=self.system_prompt, tools=tool_specs
73
+ )
74
+ self.memory.remember_turn(Message(role=Role.ASSISTANT, content=response.content))
75
+
76
+ if response.stop_reason != "tool_use":
77
+ return response
78
+
79
+ result_parts: list[Any] = []
80
+ for call in response.tool_calls:
81
+ signature = f"{call.name}:{json.dumps(call.input, sort_keys=True)}"
82
+ loop.record_action(signature)
83
+ result_parts.append(self._execute_tool_call(call.id, call.name, call.input))
84
+
85
+ self.memory.remember_turn(Message(role=Role.USER, content=result_parts))
86
+
87
+ def run_stream(self, user_input: str) -> Iterator[StreamEvent | ToolResultEvent]:
88
+ """Like `run()`, but yields StreamEvents as they arrive across
89
+ every model call in the loop — including intermediate tool-calling
90
+ turns, not just the final answer. A `ToolResultEvent` is yielded
91
+ after each tool finishes, so a UI can show "calling search..."
92
+ progress that model-level streaming alone can't express."""
93
+ self.memory.remember_turn(Message.user(user_input))
94
+ loop = self._loop_factory()
95
+ tool_specs = [t.to_spec() for t in self.tools.values()] or None
96
+
97
+ while True:
98
+ loop.step()
99
+ response: ModelResponse | None = None
100
+ for event in self.model.stream(self.memory.working.messages, system=self.system_prompt, tools=tool_specs):
101
+ yield event
102
+ if isinstance(event, MessageStop):
103
+ response = event.response
104
+
105
+ assert response is not None # every stream() implementation must end with a MessageStop
106
+ self.memory.remember_turn(Message(role=Role.ASSISTANT, content=response.content))
107
+
108
+ if response.stop_reason != "tool_use":
109
+ return
110
+
111
+ result_parts: list[Any] = []
112
+ for call in response.tool_calls:
113
+ signature = f"{call.name}:{json.dumps(call.input, sort_keys=True)}"
114
+ loop.record_action(signature)
115
+ result_part = self._execute_tool_call(call.id, call.name, call.input)
116
+ result_parts.append(result_part)
117
+ yield ToolResultEvent(tool_use_id=call.id, name=call.name, result=result_part)
118
+
119
+ self.memory.remember_turn(Message(role=Role.USER, content=result_parts))
120
+
121
+ async def arun_stream(self, user_input: str) -> AsyncIterator[StreamEvent | ToolResultEvent]:
122
+ """Async equivalent of `run_stream()`, using the model's real `astream()`."""
123
+ self.memory.remember_turn(Message.user(user_input))
124
+ loop = self._loop_factory()
125
+ tool_specs = [t.to_spec() for t in self.tools.values()] or None
126
+
127
+ while True:
128
+ loop.step()
129
+ response: ModelResponse | None = None
130
+ async for event in self.model.astream(
131
+ self.memory.working.messages, system=self.system_prompt, tools=tool_specs
132
+ ):
133
+ yield event
134
+ if isinstance(event, MessageStop):
135
+ response = event.response
136
+
137
+ assert response is not None
138
+ self.memory.remember_turn(Message(role=Role.ASSISTANT, content=response.content))
139
+
140
+ if response.stop_reason != "tool_use":
141
+ return
142
+
143
+ result_parts: list[Any] = []
144
+ for call in response.tool_calls:
145
+ signature = f"{call.name}:{json.dumps(call.input, sort_keys=True)}"
146
+ loop.record_action(signature)
147
+ result_part = self._execute_tool_call(call.id, call.name, call.input)
148
+ result_parts.append(result_part)
149
+ yield ToolResultEvent(tool_use_id=call.id, name=call.name, result=result_part)
150
+
151
+ self.memory.remember_turn(Message(role=Role.USER, content=result_parts))
152
+
153
+ def _execute_tool_call(self, call_id: str, name: str, tool_input: dict[str, Any]) -> ToolResultPart:
154
+ tool = self.tools.get(name)
155
+ if tool is None:
156
+ return ToolResultPart(tool_use_id=call_id, content=f"error: unknown tool {name!r}", is_error=True)
157
+ try:
158
+ return ToolResultPart(tool_use_id=call_id, content=tool(tool_input), is_error=False)
159
+ except Exception as exc:
160
+ return ToolResultPart(tool_use_id=call_id, content=f"error: {exc}", is_error=True)
kel/agents/events.py ADDED
@@ -0,0 +1,18 @@
1
+ """Agent-loop-level stream events. A raw `ChatModel.stream()` only covers
2
+ one model call; `Agent.run_stream()`/`arun_stream()` span the whole
3
+ multi-turn tool loop, so a `ToolResultEvent` is added to announce a tool
4
+ finished executing between model calls — the piece a UI needs to show
5
+ "calling search..." progress that plain model-level streaming can't
6
+ express on its own."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from kel.models.types import ToolResultPart
13
+
14
+
15
+ class ToolResultEvent(BaseModel):
16
+ tool_use_id: str
17
+ name: str
18
+ result: ToolResultPart
@@ -0,0 +1,133 @@
1
+ """Multi-agent orchestration patterns (DESIGN.md 3.11): sequential
2
+ pipeline, supervisor/worker, parallel fan-out with merge, swarm (peer
3
+ handoff). Each pattern shares state through a plain dict — the "shared
4
+ context bus" is not a new abstraction, it's just letting every agent
5
+ read/write the same dict instead of only seeing the last message, which is
6
+ what directly targets the "agent 3 invents context agent 2 never
7
+ produced" failure mode from DESIGN.md 1.2.
8
+
9
+ Per-agent credentials (own API key/provider vs. one shared key) aren't
10
+ special-cased here at all — that's just whatever `ChatModel` each `Agent`
11
+ was constructed with; these orchestration functions only ever call
12
+ `agent.run(...)`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from typing import Any
20
+
21
+ from kel.agents.agent import Agent
22
+ from kel.context.loop import Loop
23
+ from kel.runtime.graph import Graph
24
+
25
+
26
+ def sequential_pipeline(agents: list[Agent]) -> Graph:
27
+ """Each agent runs after the previous, seeing every prior agent's
28
+ output via shared graph state (`{agent_name}_output`), not just the
29
+ latest message."""
30
+ if not agents:
31
+ raise ValueError("sequential_pipeline requires at least one agent")
32
+
33
+ graph = Graph(entry=agents[0].name)
34
+
35
+ def make_node(agent: Agent) -> Callable[[dict[str, Any]], dict[str, Any]]:
36
+ def node(state: dict[str, Any]) -> dict[str, Any]:
37
+ upstream = "\n".join(f"[{k}]: {v}" for k, v in state.items() if k.endswith("_output"))
38
+ task = state.get("input", "")
39
+ combined = f"{upstream}\n\n{task}".strip() if upstream else task
40
+ response = agent.run(combined)
41
+ return {f"{agent.name}_output": response.text}
42
+
43
+ return node
44
+
45
+ for i, agent in enumerate(agents):
46
+ graph.add_node(agent.name, make_node(agent))
47
+ if i + 1 < len(agents):
48
+ graph.add_edge(agent.name, agents[i + 1].name)
49
+ graph.set_finish(agents[-1].name)
50
+ return graph
51
+
52
+
53
+ def run_parallel(
54
+ agents: dict[str, Agent], task: str, *, merge: Callable[[dict[str, str]], str] | None = None
55
+ ) -> dict[str, Any]:
56
+ """Fan the same task out to every agent concurrently, then merge."""
57
+ with ThreadPoolExecutor(max_workers=max(1, len(agents))) as pool:
58
+ futures = {name: pool.submit(agent.run, task) for name, agent in agents.items()}
59
+ results = {name: future.result().text for name, future in futures.items()}
60
+ merged = merge(results) if merge else "\n\n".join(f"[{k}]: {v}" for k, v in results.items())
61
+ return {"results": results, "merged": merged}
62
+
63
+
64
+ def run_supervisor(supervisor: Agent, workers: dict[str, Agent], task: str, *, max_rounds: int = 5) -> dict[str, Any]:
65
+ """Supervisor decides each round whether to delegate to a named worker
66
+ or finish, via a small text protocol (`DELEGATE: name :: instructions`
67
+ / `DONE: answer`). Bounded by Loop, same as a single agent's tool
68
+ loop — a stuck supervisor (repeating the same delegation) is caught
69
+ the same way a stuck tool-calling agent is."""
70
+ shared_state: dict[str, Any] = {"task": task, "results": {}}
71
+ loop = Loop(max_iterations=max_rounds)
72
+
73
+ while True:
74
+ loop.step()
75
+ instruction = (
76
+ f"Task: {shared_state['task']}\n"
77
+ f"Available workers: {', '.join(workers)}\n"
78
+ f"Results so far: {shared_state['results']}\n"
79
+ "Respond with exactly one line: either 'DELEGATE: <worker> :: <instructions>' "
80
+ "or 'DONE: <final answer>'."
81
+ )
82
+ decision = supervisor.run(instruction).text.strip()
83
+
84
+ if decision.upper().startswith("DONE:"):
85
+ loop.record_action("DONE")
86
+ shared_state["final_answer"] = decision[len("DONE:") :].strip()
87
+ return shared_state
88
+
89
+ if decision.upper().startswith("DELEGATE:"):
90
+ rest = decision[len("DELEGATE:") :].strip()
91
+ worker_name, _, instructions = rest.partition("::")
92
+ worker_name = worker_name.strip()
93
+ loop.record_action(f"DELEGATE:{worker_name}")
94
+ worker = workers.get(worker_name)
95
+ if worker is None:
96
+ shared_state["results"][worker_name] = f"error: unknown worker {worker_name!r}"
97
+ continue
98
+ result = worker.run(instructions.strip() or shared_state["task"])
99
+ shared_state["results"][worker_name] = result.text
100
+ else:
101
+ # supervisor didn't follow the protocol; surface its raw text
102
+ # as the final answer rather than looping forever on garbage.
103
+ shared_state["final_answer"] = decision
104
+ return shared_state
105
+
106
+
107
+ def run_swarm(agents: dict[str, Agent], start_agent: str, task: str, *, max_handoffs: int = 5) -> dict[str, Any]:
108
+ """Peer handoff: whichever agent is active can either answer directly
109
+ or hand off to a named peer via `HANDOFF: name :: note`."""
110
+ shared_state: dict[str, Any] = {"task": task, "trace": []}
111
+ loop = Loop(max_iterations=max_handoffs)
112
+ current_name, current_input = start_agent, task
113
+
114
+ while True:
115
+ loop.step()
116
+ agent = agents[current_name]
117
+ response = agent.run(current_input).text.strip()
118
+ shared_state["trace"].append({"agent": current_name, "response": response})
119
+
120
+ if response.upper().startswith("HANDOFF:"):
121
+ rest = response[len("HANDOFF:") :].strip()
122
+ next_name, _, note = rest.partition("::")
123
+ next_name = next_name.strip()
124
+ loop.record_action(f"HANDOFF:{current_name}->{next_name}")
125
+ if next_name not in agents:
126
+ shared_state["final_answer"] = response
127
+ return shared_state
128
+ current_name, current_input = next_name, note.strip() or task
129
+ continue
130
+
131
+ loop.record_action(f"ANSWER:{current_name}")
132
+ shared_state["final_answer"] = response
133
+ return shared_state
kel/agents/tool.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from kel.models.types import ToolSpec
7
+
8
+
9
+ class Tool:
10
+ def __init__(self, name: str, description: str, input_schema: dict[str, Any], fn: Callable[[dict], str]):
11
+ self.name = name
12
+ self.description = description
13
+ self.input_schema = input_schema
14
+ self.fn = fn
15
+
16
+ def to_spec(self) -> ToolSpec:
17
+ return ToolSpec(name=self.name, description=self.description, input_schema=self.input_schema)
18
+
19
+ def __call__(self, tool_input: dict[str, Any]) -> str:
20
+ return self.fn(tool_input)
kel/brain/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ from kel.brain.brain import Brain
2
+ from kel.brain.loop_control import should_continue
3
+ from kel.brain.router import EmbeddingRouter, Rule, RuleRouter
4
+ from kel.brain.scheduler import RaceResult, race_to_finish
5
+ from kel.brain.types import Route
6
+
7
+ __all__ = [
8
+ "Brain",
9
+ "EmbeddingRouter",
10
+ "RaceResult",
11
+ "Route",
12
+ "Rule",
13
+ "RuleRouter",
14
+ "race_to_finish",
15
+ "should_continue",
16
+ ]
kel/brain/brain.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from kel.brain.types import Route
7
+ from kel.observability.tracer import Tracer, get_tracer
8
+
9
+ FastTier = Callable[[Any], "Route | None"]
10
+ SlowTier = Callable[[Any], Route]
11
+
12
+
13
+ class Brain:
14
+ """Two-tier router (DESIGN.md 3.14): try the fast tier first; escalate
15
+ to the slow tier (typically an LLM call) only when the fast tier has
16
+ no confident answer. Every decision is traced — which tier answered,
17
+ at what confidence — so a bad routing call is debuggable."""
18
+
19
+ def __init__(
20
+ self,
21
+ *,
22
+ fast_tier: FastTier | None = None,
23
+ slow_tier: SlowTier | None = None,
24
+ confidence_threshold: float = 0.6,
25
+ ):
26
+ self.fast_tier = fast_tier
27
+ self.slow_tier = slow_tier
28
+ self.confidence_threshold = confidence_threshold
29
+
30
+ def route(self, state: Any, *, tracer: Tracer | None = None) -> Route:
31
+ tracer = tracer or get_tracer()
32
+ with tracer.span("kel.brain.route") as span:
33
+ fast_route: Route | None = None
34
+ if self.fast_tier is not None:
35
+ fast_route = self.fast_tier(state)
36
+ if fast_route is not None:
37
+ span.set_attribute("fast_confidence", fast_route.confidence)
38
+ if fast_route is not None and fast_route.confidence >= self.confidence_threshold:
39
+ span.set_attribute("decided_tier", "fast")
40
+ span.set_attribute("target", fast_route.target)
41
+ return fast_route
42
+
43
+ if self.slow_tier is not None:
44
+ slow_route = self.slow_tier(state)
45
+ span.set_attribute("decided_tier", "slow")
46
+ span.set_attribute("target", slow_route.target)
47
+ return slow_route
48
+
49
+ if fast_route is not None:
50
+ span.set_attribute("decided_tier", "fast_low_confidence_fallback")
51
+ span.set_attribute("target", fast_route.target)
52
+ return fast_route
53
+
54
+ raise RuntimeError(
55
+ "Brain could not produce a route: fast tier found no match and no slow tier is configured"
56
+ )
@@ -0,0 +1,22 @@
1
+ """Budget-aware continue-vs-stop decision (DESIGN.md 3.14). Honest v1
2
+ scope: this is a fixed-reserve threshold check, not real marginal-value
3
+ estimation — actually modeling "is another iteration worth it" needs
4
+ outcome data (did past extra iterations actually help?) this project
5
+ doesn't have yet. It's a placeholder for that, with the same call
6
+ signature a smarter version could fill in later."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from kel.budget.types import BudgetSnapshot
11
+
12
+
13
+ def should_continue(
14
+ snapshot: BudgetSnapshot, *, min_tokens_reserve: int = 200, min_cost_reserve: float = 0.01
15
+ ) -> bool:
16
+ if snapshot.tokens_remaining is not None and snapshot.tokens_remaining < min_tokens_reserve:
17
+ return False
18
+ if snapshot.cost_usd_remaining is not None and snapshot.cost_usd_remaining < min_cost_reserve:
19
+ return False
20
+ if snapshot.wall_seconds_remaining is not None and snapshot.wall_seconds_remaining <= 0:
21
+ return False
22
+ return True
kel/brain/router.py ADDED
@@ -0,0 +1,75 @@
1
+ """Fast-tier routers (DESIGN.md 3.14, descoped per §7): rules and
2
+ embedding-similarity, deliberately *not* a custom-trained model — that
3
+ needs a training pipeline and real traffic volume this project doesn't
4
+ have on day one. Both expose `predict_route(...) -> Route | None`
5
+ (`None` means "no confident match, ask the slow tier"), the tiny
6
+ interface a genuinely learned router could later drop in behind."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from kel.brain.types import Route
16
+
17
+
18
+ @dataclass
19
+ class Rule:
20
+ predicate: Callable[[dict[str, Any]], bool]
21
+ target: str
22
+ confidence: float = 1.0
23
+ reason: str = "rule matched"
24
+
25
+
26
+ class RuleRouter:
27
+ def __init__(self, rules: list[Rule] | None = None, *, default: str | None = None, default_confidence: float = 0.3):
28
+ self.rules: list[Rule] = list(rules or [])
29
+ self.default = default
30
+ self.default_confidence = default_confidence
31
+
32
+ def add_rule(self, predicate: Callable[[dict[str, Any]], bool], target: str, confidence: float = 1.0) -> None:
33
+ self.rules.append(Rule(predicate=predicate, target=target, confidence=confidence))
34
+
35
+ def predict_route(self, state: dict[str, Any]) -> Route | None:
36
+ for rule in self.rules:
37
+ if rule.predicate(state):
38
+ return Route(target=rule.target, confidence=rule.confidence, tier="fast", reason=rule.reason)
39
+ if self.default is not None:
40
+ return Route(target=self.default, confidence=self.default_confidence, tier="fast", reason="default fallback")
41
+ return None
42
+
43
+
44
+ def _cosine_similarity(a: list[float], b: list[float]) -> float:
45
+ dot = sum(x * y for x, y in zip(a, b, strict=True))
46
+ norm_a = math.sqrt(sum(x * x for x in a))
47
+ norm_b = math.sqrt(sum(y * y for y in b))
48
+ return 0.0 if norm_a == 0 or norm_b == 0 else dot / (norm_a * norm_b)
49
+
50
+
51
+ class EmbeddingRouter:
52
+ """Nearest-neighbor routing over a small set of labeled examples —
53
+ "if this looks like past query X, route the way X was routed." Grows
54
+ more useful as more examples are added over time (e.g. fed by
55
+ kel.heal's failure->fix history), without ever requiring a training
56
+ step."""
57
+
58
+ def __init__(self, embedder: Callable[[str], list[float]], examples: list[tuple[str, str]] | None = None):
59
+ self.embedder = embedder
60
+ self._examples: list[tuple[list[float], str]] = []
61
+ for text, target in examples or []:
62
+ self.add_example(text, target)
63
+
64
+ def add_example(self, text: str, target: str) -> None:
65
+ self._examples.append((self.embedder(text), target))
66
+
67
+ def predict_route(self, query_text: str) -> Route | None:
68
+ if not self._examples:
69
+ return None
70
+ query_vec = self.embedder(query_text)
71
+ best_score, best_target = max(
72
+ ((_cosine_similarity(query_vec, vec), target) for vec, target in self._examples),
73
+ key=lambda pair: pair[0],
74
+ )
75
+ return Route(target=best_target, confidence=best_score, tier="fast", reason="nearest example match")
kel/brain/scheduler.py ADDED
@@ -0,0 +1,44 @@
1
+ """Parallel-to-finish scheduling (DESIGN.md 3.14): run redundant branches
2
+ concurrently, stop waiting as soon as one produces a sufficient result.
3
+
4
+ Honest limitation: Python cannot forcibly interrupt a running thread.
5
+ "Cancelling" a losing branch here means the caller stops waiting on it and
6
+ its result is discarded — already-started branches keep running to
7
+ completion in the background rather than being killed. Real cancellation
8
+ would need cooperative checkpoints inside each branch or a process-based
9
+ (not thread-based) executor; that's future work, not something to claim
10
+ this does today.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import concurrent.futures as cf
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+
21
+ @dataclass
22
+ class RaceResult:
23
+ winner: str | None
24
+ result: Any
25
+
26
+
27
+ def race_to_finish(
28
+ branches: dict[str, Callable[[], Any]], *, is_sufficient: Callable[[Any], bool] | None = None
29
+ ) -> RaceResult:
30
+ is_sufficient = is_sufficient or (lambda _result: True)
31
+ pool = cf.ThreadPoolExecutor(max_workers=max(1, len(branches)))
32
+ futures = {pool.submit(fn): name for name, fn in branches.items()}
33
+ winner_name: str | None = None
34
+ winner_result: Any = None
35
+ try:
36
+ for future in cf.as_completed(futures):
37
+ name = futures[future]
38
+ result = future.result()
39
+ if is_sufficient(result):
40
+ winner_name, winner_result = name, result
41
+ break
42
+ finally:
43
+ pool.shutdown(wait=False, cancel_futures=True)
44
+ return RaceResult(winner=winner_name, result=winner_result)
kel/brain/types.py ADDED
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class Route(BaseModel):
9
+ target: str
10
+ confidence: float
11
+ tier: Literal["fast", "slow"]
12
+ reason: str | None = None
kel/budget/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from kel.budget.budgeted import BudgetedChatModel
2
+ from kel.budget.errors import BudgetExceededError
3
+ from kel.budget.pricing import estimate_cost_usd, is_priced
4
+ from kel.budget.tracker import BudgetTracker
5
+ from kel.budget.types import Budget, BudgetSnapshot
6
+
7
+ __all__ = [
8
+ "Budget",
9
+ "BudgetExceededError",
10
+ "BudgetSnapshot",
11
+ "BudgetTracker",
12
+ "BudgetedChatModel",
13
+ "estimate_cost_usd",
14
+ "is_priced",
15
+ ]