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.
- kel/__init__.py +47 -0
- kel/agents/__init__.py +14 -0
- kel/agents/agent.py +160 -0
- kel/agents/events.py +18 -0
- kel/agents/orchestration.py +133 -0
- kel/agents/tool.py +20 -0
- kel/brain/__init__.py +16 -0
- kel/brain/brain.py +56 -0
- kel/brain/loop_control.py +22 -0
- kel/brain/router.py +75 -0
- kel/brain/scheduler.py +44 -0
- kel/brain/types.py +12 -0
- kel/budget/__init__.py +15 -0
- kel/budget/budgeted.py +55 -0
- kel/budget/errors.py +11 -0
- kel/budget/pricing.py +29 -0
- kel/budget/tracker.py +74 -0
- kel/budget/types.py +27 -0
- kel/caching/__init__.py +11 -0
- kel/caching/cache.py +53 -0
- kel/caching/cached.py +101 -0
- kel/caching/key.py +36 -0
- kel/context/__init__.py +18 -0
- kel/context/errors.py +19 -0
- kel/context/eviction.py +41 -0
- kel/context/loop.py +64 -0
- kel/context/tokens.py +26 -0
- kel/context/window.py +51 -0
- kel/heal/__init__.py +15 -0
- kel/heal/diagnoser.py +51 -0
- kel/heal/errors.py +12 -0
- kel/heal/healer.py +74 -0
- kel/heal/learning.py +19 -0
- kel/heal/types.py +20 -0
- kel/memory/__init__.py +18 -0
- kel/memory/consolidation.py +29 -0
- kel/memory/episodic.py +53 -0
- kel/memory/memory.py +33 -0
- kel/memory/procedural.py +30 -0
- kel/memory/semantic.py +70 -0
- kel/memory/working.py +13 -0
- kel/models/__init__.py +57 -0
- kel/models/base.py +88 -0
- kel/models/errors.py +31 -0
- kel/models/providers/__init__.py +0 -0
- kel/models/providers/anthropic.py +277 -0
- kel/models/providers/cohere.py +399 -0
- kel/models/providers/gemini.py +347 -0
- kel/models/providers/mistral.py +266 -0
- kel/models/providers/openai.py +380 -0
- kel/models/registry.py +191 -0
- kel/models/structured.py +132 -0
- kel/models/types.py +126 -0
- kel/monitoring/__init__.py +4 -0
- kel/monitoring/dashboard.py +139 -0
- kel/monitoring/metrics.py +116 -0
- kel/observability/__init__.py +17 -0
- kel/observability/instrumented.py +59 -0
- kel/observability/otel.py +58 -0
- kel/observability/sinks.py +43 -0
- kel/observability/tracer.py +93 -0
- kel/observability/types.py +17 -0
- kel/prompting/__init__.py +13 -0
- kel/prompting/cot.py +23 -0
- kel/prompting/few_shot.py +29 -0
- kel/prompting/react.py +30 -0
- kel/py.typed +0 -0
- kel/ratelimit/__init__.py +4 -0
- kel/ratelimit/limiter.py +71 -0
- kel/ratelimit/rate_limited.py +43 -0
- kel/realtime/__init__.py +9 -0
- kel/realtime/dual_path.py +31 -0
- kel/realtime/providers.py +26 -0
- kel/retrieval/__init__.py +20 -0
- kel/retrieval/chroma_store.py +99 -0
- kel/retrieval/embeddings.py +47 -0
- kel/retrieval/loaders.py +40 -0
- kel/retrieval/pgvector_store.py +131 -0
- kel/retrieval/pinecone_store.py +110 -0
- kel/retrieval/qdrant.py +133 -0
- kel/retrieval/retriever.py +73 -0
- kel/retrieval/splitter.py +78 -0
- kel/retrieval/store.py +71 -0
- kel/retrieval/types.py +17 -0
- kel/retrieval/weaviate_store.py +123 -0
- kel/runtime/__init__.py +16 -0
- kel/runtime/checkpoint.py +41 -0
- kel/runtime/executor.py +141 -0
- kel/runtime/graph.py +57 -0
- kel/runtime/interrupt.py +16 -0
- kel/sdk/__init__.py +11 -0
- kel/sdk/banner.py +44 -0
- kel/sdk/build.py +32 -0
- kel/sdk/cli.py +91 -0
- kel/sdk/serve.py +84 -0
- kel/specs/__init__.py +22 -0
- kel/specs/eval.py +40 -0
- kel/specs/frontmatter.py +21 -0
- kel/specs/llm_eval.py +63 -0
- kel/specs/loader.py +47 -0
- kel/specs/types.py +35 -0
- kel/storage/__init__.py +10 -0
- kel/storage/artifacts.py +47 -0
- kel/storage/blob.py +50 -0
- kel/storage/checkpoint_store.py +98 -0
- kel/storage/s3.py +58 -0
- kel/testing/__init__.py +23 -0
- kel/testing/assertions.py +44 -0
- kel/testing/cassette.py +29 -0
- kel/testing/recording.py +38 -0
- kel/testing/replay.py +42 -0
- kel/tools/__init__.py +33 -0
- kel/tools/code_exec.py +61 -0
- kel/tools/search_registry.py +62 -0
- kel/tools/shell_tool.py +57 -0
- kel/tools/sql_tool.py +68 -0
- kel/tools/web_fetch.py +99 -0
- kel/tools/web_search.py +340 -0
- pykel-1.0.0.dist-info/METADATA +202 -0
- pykel-1.0.0.dist-info/RECORD +123 -0
- pykel-1.0.0.dist-info/WHEEL +4 -0
- pykel-1.0.0.dist-info/entry_points.txt +2 -0
- 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
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
|
+
]
|