krnl-code 1.0.4__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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/mcp_client.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) client integration.
|
|
2
|
+
|
|
3
|
+
Connects to external MCP servers (stdio or SSE/HTTP) and exposes their tools to
|
|
4
|
+
the agent as OpenAI-style function schemas named ``mcp__<server>__<tool>``. This
|
|
5
|
+
is the universal, model-independent extensibility layer: any MCP server's tools
|
|
6
|
+
become available to whatever model you're running.
|
|
7
|
+
|
|
8
|
+
Entirely optional and guarded: if the `mcp` package isn't installed or no servers
|
|
9
|
+
are configured, this is a no-op and the core agent is unaffected.
|
|
10
|
+
|
|
11
|
+
Config (config.yaml):
|
|
12
|
+
mcp:
|
|
13
|
+
servers:
|
|
14
|
+
filesystem:
|
|
15
|
+
command: npx
|
|
16
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
|
|
17
|
+
env: {}
|
|
18
|
+
my_http:
|
|
19
|
+
url: https://example.com/mcp # SSE endpoint
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from contextlib import AsyncExitStack
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mcp_available() -> bool:
|
|
28
|
+
try:
|
|
29
|
+
import mcp # noqa: F401
|
|
30
|
+
|
|
31
|
+
return True
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _tool_name(server: str, tool: str) -> str:
|
|
37
|
+
return f"mcp__{server}__{tool}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MCPManager:
|
|
41
|
+
def __init__(self, servers: dict):
|
|
42
|
+
self.servers = servers or {}
|
|
43
|
+
self._stack: Optional[AsyncExitStack] = None
|
|
44
|
+
self._sessions: dict[str, Any] = {}
|
|
45
|
+
self._schemas: list[dict] = []
|
|
46
|
+
self._route: dict[str, tuple[str, str]] = {} # full name -> (server, tool)
|
|
47
|
+
self.errors: list[str] = []
|
|
48
|
+
self.started = False
|
|
49
|
+
|
|
50
|
+
def schemas(self) -> list[dict]:
|
|
51
|
+
return self._schemas
|
|
52
|
+
|
|
53
|
+
async def start(self) -> None:
|
|
54
|
+
if self.started or not self.servers or not mcp_available():
|
|
55
|
+
self.started = True
|
|
56
|
+
return
|
|
57
|
+
from mcp import ClientSession, StdioServerParameters
|
|
58
|
+
from mcp.client.stdio import stdio_client
|
|
59
|
+
|
|
60
|
+
self._stack = AsyncExitStack()
|
|
61
|
+
for name, conf in self.servers.items():
|
|
62
|
+
try:
|
|
63
|
+
if conf.get("url"):
|
|
64
|
+
from mcp.client.sse import sse_client
|
|
65
|
+
|
|
66
|
+
read, write = await self._stack.enter_async_context(
|
|
67
|
+
sse_client(conf["url"])
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
params = StdioServerParameters(
|
|
71
|
+
command=conf["command"],
|
|
72
|
+
args=conf.get("args", []),
|
|
73
|
+
env=conf.get("env") or None,
|
|
74
|
+
)
|
|
75
|
+
read, write = await self._stack.enter_async_context(stdio_client(params))
|
|
76
|
+
session = await self._stack.enter_async_context(ClientSession(read, write))
|
|
77
|
+
await session.initialize()
|
|
78
|
+
self._sessions[name] = session
|
|
79
|
+
listed = await session.list_tools()
|
|
80
|
+
for t in listed.tools:
|
|
81
|
+
full = _tool_name(name, t.name)
|
|
82
|
+
self._route[full] = (name, t.name)
|
|
83
|
+
self._schemas.append(
|
|
84
|
+
{
|
|
85
|
+
"type": "function",
|
|
86
|
+
"function": {
|
|
87
|
+
"name": full,
|
|
88
|
+
"description": (t.description or "")[:1024],
|
|
89
|
+
"parameters": t.inputSchema or {"type": "object", "properties": {}},
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
except Exception as e: # noqa: BLE001
|
|
94
|
+
self.errors.append(f"{name}: {e}")
|
|
95
|
+
self.started = True
|
|
96
|
+
|
|
97
|
+
async def call(self, full_name: str, args: dict) -> str:
|
|
98
|
+
route = self._route.get(full_name)
|
|
99
|
+
if not route:
|
|
100
|
+
return f"unknown MCP tool: {full_name}"
|
|
101
|
+
server, tool = route
|
|
102
|
+
session = self._sessions.get(server)
|
|
103
|
+
if not session:
|
|
104
|
+
return f"MCP server not connected: {server}"
|
|
105
|
+
try:
|
|
106
|
+
result = await session.call_tool(tool, args or {})
|
|
107
|
+
except Exception as e: # noqa: BLE001
|
|
108
|
+
return f"MCP tool error: {e}"
|
|
109
|
+
# result.content is a list of content blocks
|
|
110
|
+
parts: list[str] = []
|
|
111
|
+
for block in getattr(result, "content", []) or []:
|
|
112
|
+
text = getattr(block, "text", None)
|
|
113
|
+
parts.append(text if text is not None else str(block))
|
|
114
|
+
return "\n".join(parts) if parts else "(no content)"
|
|
115
|
+
|
|
116
|
+
async def stop(self) -> None:
|
|
117
|
+
if self._stack:
|
|
118
|
+
try:
|
|
119
|
+
await self._stack.aclose()
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
self._stack = None
|
|
123
|
+
self._sessions.clear()
|
|
124
|
+
self.started = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_mcp_tool(name: str) -> bool:
|
|
128
|
+
return name.startswith("mcp__")
|
krnl_agent/memory.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Project & user memory — persistent instructions auto-loaded into context.
|
|
2
|
+
|
|
3
|
+
Looks for memory files in the workspace (and a global one) and injects them into
|
|
4
|
+
the system prompt, so project conventions and standing instructions are always in
|
|
5
|
+
context (like Claude Code's CLAUDE.md / AGENTS.md).
|
|
6
|
+
|
|
7
|
+
Precedence (all concatenated): user-global → project.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .settings import SETTINGS_DIR
|
|
14
|
+
|
|
15
|
+
PROJECT_FILES = ["AGENTS.md", "KRNL.md", "CLAUDE.md", ".krnl/AGENTS.md"]
|
|
16
|
+
USER_FILE = SETTINGS_DIR / "AGENTS.md"
|
|
17
|
+
_MAX = 12000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_memory(workspace: str) -> str:
|
|
21
|
+
parts: list[str] = []
|
|
22
|
+
if USER_FILE.is_file():
|
|
23
|
+
try:
|
|
24
|
+
parts.append("# User memory (global)\n" + USER_FILE.read_text(encoding="utf-8")[:_MAX])
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
root = Path(workspace)
|
|
28
|
+
for name in PROJECT_FILES:
|
|
29
|
+
p = root / name
|
|
30
|
+
if p.is_file():
|
|
31
|
+
try:
|
|
32
|
+
parts.append(f"# Project memory ({name})\n" + p.read_text(encoding="utf-8")[:_MAX])
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
break # first project memory file wins
|
|
36
|
+
return "\n\n".join(parts)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
TEMPLATE = """# Project memory for the Krnl Agent
|
|
40
|
+
|
|
41
|
+
This file is automatically loaded into the agent's context. Put standing
|
|
42
|
+
instructions and project conventions here.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
- Build:
|
|
46
|
+
- Test:
|
|
47
|
+
- Lint:
|
|
48
|
+
|
|
49
|
+
## Conventions
|
|
50
|
+
- (e.g. "use 4-space indent", "prefer async", "never touch generated/")
|
|
51
|
+
|
|
52
|
+
## Notes
|
|
53
|
+
- (anything the agent should always know about this project)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_template(workspace: str) -> Path:
|
|
58
|
+
path = Path(workspace) / "AGENTS.md"
|
|
59
|
+
if not path.exists():
|
|
60
|
+
path.write_text(TEMPLATE, encoding="utf-8")
|
|
61
|
+
return path
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Multi-model routing — one model per job, across providers, cost-aware.
|
|
2
|
+
|
|
3
|
+
You can assign a different model (from any configured provider) to each *phase* of
|
|
4
|
+
the agent's work:
|
|
5
|
+
|
|
6
|
+
models: # named roles; each may be a different provider
|
|
7
|
+
planner: { provider: anthropic, model: claude-opus-4-8 }
|
|
8
|
+
executor: { provider: openai, model: gpt-5.5 }
|
|
9
|
+
cheap: { provider: groq, model: llama-3.3-70b-versatile }
|
|
10
|
+
verifier: { provider: gemini, model: gemini-2.5-flash }
|
|
11
|
+
routing:
|
|
12
|
+
strategy: auto # auto = cheap-where-safe + escalate on trouble
|
|
13
|
+
plan: planner # model used in plan mode
|
|
14
|
+
execute: executor # model for normal execution
|
|
15
|
+
subagent: cheap # model for delegated sub-agents
|
|
16
|
+
verify: cheap # model for the post-edit verifier
|
|
17
|
+
escalate: planner # stronger model to jump to when quality slips
|
|
18
|
+
escalate_after: 2 # consecutive all-failed steps before escalating
|
|
19
|
+
|
|
20
|
+
The goal is to spend the least money that still gets the job done: cheap models for
|
|
21
|
+
mechanical / parallel work, your main model for execution, a strong reasoning model
|
|
22
|
+
for planning — and an automatic **escalation** to a stronger model when the cheaper
|
|
23
|
+
one keeps failing. If nothing is configured, behaviour is identical to before
|
|
24
|
+
(single active model, with `router.cheap` / `subagent_model` still honored).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import replace
|
|
29
|
+
|
|
30
|
+
from . import pricing
|
|
31
|
+
from .config import AgentConfig, Config, ProviderConfig
|
|
32
|
+
from .llm import build_client
|
|
33
|
+
|
|
34
|
+
# Phase -> ordered fallback role names if the phase isn't explicitly routed.
|
|
35
|
+
_PHASE_DEFAULTS = {
|
|
36
|
+
"plan": ["planner", "plan", "heavy", "executor", "execute"],
|
|
37
|
+
"execute": ["executor", "execute"],
|
|
38
|
+
"subagent": ["subagent", "cheap", "executor", "execute"],
|
|
39
|
+
"verify": ["verifier", "verify", "cheap", "executor", "execute"],
|
|
40
|
+
"escalate": ["escalate", "heavy", "planner", "executor", "execute"],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ModelRouter:
|
|
45
|
+
def __init__(self, config: Config):
|
|
46
|
+
self.config = config
|
|
47
|
+
self.routing: dict = dict(config.routing or {})
|
|
48
|
+
self.strategy = str(self.routing.get("strategy", "auto")).lower()
|
|
49
|
+
self.escalate_after = int(self.routing.get("escalate_after", 2))
|
|
50
|
+
self.roles: dict[str, dict] = self._build_roles()
|
|
51
|
+
self._clients: dict[str, object] = {}
|
|
52
|
+
|
|
53
|
+
# --- role resolution -------------------------------------------------- #
|
|
54
|
+
def _build_roles(self) -> dict[str, dict]:
|
|
55
|
+
roles: dict[str, dict] = {}
|
|
56
|
+
for name, entry in (self.config.models or {}).items():
|
|
57
|
+
if isinstance(entry, str): # shorthand: "role: model-id"
|
|
58
|
+
entry = {"model": entry}
|
|
59
|
+
roles[name] = dict(entry or {})
|
|
60
|
+
# Implicit roles for back-compat / zero-config.
|
|
61
|
+
roles.setdefault("execute", {"provider": self.config.provider.name,
|
|
62
|
+
"model": self.config.provider.model})
|
|
63
|
+
roles.setdefault("executor", roles["execute"])
|
|
64
|
+
cheap = (self.config.router or {}).get("cheap") or self.config.subagent_model
|
|
65
|
+
if cheap and "cheap" not in roles:
|
|
66
|
+
roles["cheap"] = {"model": cheap}
|
|
67
|
+
heavy = (self.config.router or {}).get("heavy")
|
|
68
|
+
if heavy and "heavy" not in roles:
|
|
69
|
+
roles["heavy"] = {"model": heavy}
|
|
70
|
+
return roles
|
|
71
|
+
|
|
72
|
+
def role_for_phase(self, phase: str) -> str:
|
|
73
|
+
explicit = self.routing.get(phase)
|
|
74
|
+
if explicit and explicit in self.roles:
|
|
75
|
+
return explicit
|
|
76
|
+
for cand in _PHASE_DEFAULTS.get(phase, ["execute"]):
|
|
77
|
+
if cand in self.roles:
|
|
78
|
+
return cand
|
|
79
|
+
return "execute"
|
|
80
|
+
|
|
81
|
+
def _provider_for_role(self, role: str) -> ProviderConfig:
|
|
82
|
+
entry = self.roles.get(role, {})
|
|
83
|
+
base_name = entry.get("provider") or self.config.provider.name
|
|
84
|
+
# Prefer the live active provider (it may carry a runtime-injected key).
|
|
85
|
+
if base_name == self.config.provider.name:
|
|
86
|
+
base = self.config.provider
|
|
87
|
+
else:
|
|
88
|
+
base = self.config.all_providers.get(base_name, self.config.provider)
|
|
89
|
+
over: dict = {}
|
|
90
|
+
if entry.get("model"):
|
|
91
|
+
over["model"] = entry["model"]
|
|
92
|
+
if "temperature" in entry:
|
|
93
|
+
over["temperature"] = float(entry["temperature"])
|
|
94
|
+
if "max_tokens" in entry:
|
|
95
|
+
over["max_tokens"] = int(entry["max_tokens"])
|
|
96
|
+
return replace(base, **over) if over else base
|
|
97
|
+
|
|
98
|
+
def _agent_for_role(self, role: str) -> AgentConfig:
|
|
99
|
+
entry = self.roles.get(role, {})
|
|
100
|
+
if "fallback_models" in entry:
|
|
101
|
+
return replace(self.config.agent, fallback_models=list(entry["fallback_models"]))
|
|
102
|
+
return self.config.agent
|
|
103
|
+
|
|
104
|
+
# --- public API ------------------------------------------------------- #
|
|
105
|
+
def provider_for_phase(self, phase: str) -> ProviderConfig:
|
|
106
|
+
return self._provider_for_role(self.role_for_phase(phase))
|
|
107
|
+
|
|
108
|
+
def model_for_phase(self, phase: str) -> str:
|
|
109
|
+
return self.provider_for_phase(phase).model
|
|
110
|
+
|
|
111
|
+
def client_for_phase(self, phase: str):
|
|
112
|
+
role = self.role_for_phase(phase)
|
|
113
|
+
if role not in self._clients:
|
|
114
|
+
self._clients[role] = build_client(self._provider_for_role(role),
|
|
115
|
+
self._agent_for_role(role))
|
|
116
|
+
return self._clients[role]
|
|
117
|
+
|
|
118
|
+
def is_default_phase(self, phase: str) -> bool:
|
|
119
|
+
"""True if `phase` resolves to the active provider's model (no divergence).
|
|
120
|
+
Lets callers reuse an injected/active client instead of building a new one."""
|
|
121
|
+
p = self.provider_for_phase(phase)
|
|
122
|
+
return p.name == self.config.provider.name and p.model == self.config.provider.model
|
|
123
|
+
|
|
124
|
+
def has_distinct_escalation(self) -> bool:
|
|
125
|
+
"""True if the escalate phase resolves to a different model than execute."""
|
|
126
|
+
return self.model_for_phase("escalate") != self.model_for_phase("execute")
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _blended_rate(model: str, overrides: dict | None = None) -> float | None:
|
|
130
|
+
r = pricing.rate_for(model, overrides)
|
|
131
|
+
if r is None:
|
|
132
|
+
return None
|
|
133
|
+
# Weight output heavier (it dominates agent cost): input + 3*output.
|
|
134
|
+
return r[0] + 3 * r[1]
|
|
135
|
+
|
|
136
|
+
def summary(self) -> list[dict]:
|
|
137
|
+
"""One row per phase: which role/provider/model and its price (visibility)."""
|
|
138
|
+
rows = []
|
|
139
|
+
for phase in ("plan", "execute", "subagent", "verify", "escalate"):
|
|
140
|
+
role = self.role_for_phase(phase)
|
|
141
|
+
p = self._provider_for_role(role)
|
|
142
|
+
rate = pricing.rate_for(p.model, self.config.pricing)
|
|
143
|
+
rows.append({
|
|
144
|
+
"phase": phase,
|
|
145
|
+
"role": role,
|
|
146
|
+
"provider": p.name,
|
|
147
|
+
"model": p.model,
|
|
148
|
+
"in_per_1m": rate[0] if rate else None,
|
|
149
|
+
"out_per_1m": rate[1] if rate else None,
|
|
150
|
+
})
|
|
151
|
+
return rows
|
krnl_agent/monitor.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Runtime monitoring & observability wiring.
|
|
2
|
+
|
|
3
|
+
The missing pillar in the field: once an app is deployed, give it eyes. This module
|
|
4
|
+
helps the agent (1) instrument the app with error tracking + telemetry, (2) make sure
|
|
5
|
+
a health endpoint exists, (3) register an uptime check, and (4) report current status
|
|
6
|
+
back into the terminal. Everything is credential-by-env-var and network calls are
|
|
7
|
+
best-effort (skipped cleanly when a token isn't set).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
# Health-endpoint snippets the agent can drop into a project that lacks one.
|
|
17
|
+
HEALTH_SNIPPETS = {
|
|
18
|
+
"fastapi": (
|
|
19
|
+
'@app.get("/health")\n'
|
|
20
|
+
'def health():\n'
|
|
21
|
+
' return {"status": "ok"}\n'),
|
|
22
|
+
"flask": (
|
|
23
|
+
'@app.get("/health")\n'
|
|
24
|
+
'def health():\n'
|
|
25
|
+
' return {"status": "ok"}, 200\n'),
|
|
26
|
+
"express": (
|
|
27
|
+
"app.get('/health', (req, res) => res.json({ status: 'ok' }));\n"),
|
|
28
|
+
"next": (
|
|
29
|
+
"// app/health/route.ts\n"
|
|
30
|
+
"export function GET() { return Response.json({ status: 'ok' }); }\n"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def instrument_env() -> dict[str, str]:
|
|
35
|
+
"""Env vars to inject into the deployed service for monitoring, based on what the
|
|
36
|
+
user has configured locally. Only includes a var if its source is present."""
|
|
37
|
+
out: dict[str, str] = {}
|
|
38
|
+
if os.getenv("SENTRY_DSN"):
|
|
39
|
+
out["SENTRY_DSN"] = os.environ["SENTRY_DSN"]
|
|
40
|
+
if os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"):
|
|
41
|
+
out["OTEL_EXPORTER_OTLP_ENDPOINT"] = os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"]
|
|
42
|
+
if os.getenv("OTEL_EXPORTER_OTLP_HEADERS"):
|
|
43
|
+
out["OTEL_EXPORTER_OTLP_HEADERS"] = os.environ["OTEL_EXPORTER_OTLP_HEADERS"]
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def configured_providers() -> dict[str, bool]:
|
|
48
|
+
return {
|
|
49
|
+
"sentry": bool(os.getenv("SENTRY_DSN") or os.getenv("SENTRY_AUTH_TOKEN")),
|
|
50
|
+
"opentelemetry": bool(os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")),
|
|
51
|
+
"datadog": bool(os.getenv("DD_API_KEY")),
|
|
52
|
+
"uptimerobot": bool(os.getenv("UPTIMEROBOT_API_KEY")),
|
|
53
|
+
"betterstack": bool(os.getenv("BETTERSTACK_TOKEN")),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register_uptime_command(url: str) -> tuple[bool, str]:
|
|
58
|
+
"""Build a headless command to register an uptime monitor for `url`."""
|
|
59
|
+
if os.getenv("UPTIMEROBOT_API_KEY"):
|
|
60
|
+
return True, (
|
|
61
|
+
'curl -s -X POST https://api.uptimerobot.com/v3/monitors '
|
|
62
|
+
'-H "Authorization: Bearer $UPTIMEROBOT_API_KEY" '
|
|
63
|
+
'-H "Content-Type: application/json" '
|
|
64
|
+
f'-d \'{{"type":"http","url":"{url}","interval":300,"friendlyName":"krnl"}}\'')
|
|
65
|
+
if os.getenv("BETTERSTACK_TOKEN"):
|
|
66
|
+
return True, (
|
|
67
|
+
'curl -s -X POST https://uptime.betterstack.com/api/v2/monitors '
|
|
68
|
+
'-H "Authorization: Bearer $BETTERSTACK_TOKEN" '
|
|
69
|
+
'-H "Content-Type: application/json" '
|
|
70
|
+
f'-d \'{{"monitor_type":"status","url":"{url}"}}\'')
|
|
71
|
+
return False, ("No uptime provider configured. Set UPTIMEROBOT_API_KEY or "
|
|
72
|
+
"BETTERSTACK_TOKEN to auto-register a check.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _sentry_unresolved() -> tuple[int, list[str]] | None:
|
|
76
|
+
"""Best-effort: count unresolved Sentry issues. Returns None if not configured
|
|
77
|
+
or the call fails (never raises)."""
|
|
78
|
+
token = os.getenv("SENTRY_AUTH_TOKEN")
|
|
79
|
+
org = os.getenv("SENTRY_ORG")
|
|
80
|
+
project = os.getenv("SENTRY_PROJECT")
|
|
81
|
+
if not (token and org and project):
|
|
82
|
+
return None
|
|
83
|
+
url = (f"https://sentry.io/api/0/projects/{org}/{project}/issues/"
|
|
84
|
+
"?query=is:unresolved&statsPeriod=24h")
|
|
85
|
+
try:
|
|
86
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
|
87
|
+
with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
|
|
88
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
89
|
+
titles = [i.get("title", "?") for i in data[:5]]
|
|
90
|
+
return len(data), titles
|
|
91
|
+
except (urllib.error.URLError, ValueError, TimeoutError, Exception): # noqa: BLE001
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def status() -> str:
|
|
96
|
+
"""Human-readable monitoring status for the `monitor` command."""
|
|
97
|
+
prov = configured_providers()
|
|
98
|
+
lines = ["Monitoring status", ""]
|
|
99
|
+
for name, on in prov.items():
|
|
100
|
+
lines.append(f" {'✔' if on else '○'} {name}: {'configured' if on else 'not set'}")
|
|
101
|
+
sentry = _sentry_unresolved()
|
|
102
|
+
if sentry is not None:
|
|
103
|
+
n, titles = sentry
|
|
104
|
+
lines += ["", f"Sentry: {n} unresolved issue(s) in the last 24h:"]
|
|
105
|
+
lines += [f" - {t}" for t in titles] or [" (none)"]
|
|
106
|
+
elif prov["sentry"]:
|
|
107
|
+
lines += ["", "Sentry: set SENTRY_AUTH_TOKEN + SENTRY_ORG + SENTRY_PROJECT to "
|
|
108
|
+
"pull live issues."]
|
|
109
|
+
if not any(prov.values()):
|
|
110
|
+
lines += ["", "No monitoring configured yet. `krnl-agent ship` wires Sentry/OTel "
|
|
111
|
+
"+ an uptime check automatically when their tokens are present."]
|
|
112
|
+
return "\n".join(lines)
|
krnl_agent/notify.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Messaging connectors — push agent results to Slack / Discord / Telegram.
|
|
2
|
+
|
|
3
|
+
Config (config.yaml):
|
|
4
|
+
notifications:
|
|
5
|
+
on: [done, error] # which events to notify on
|
|
6
|
+
slack_webhook: https://hooks.slack.com/services/...
|
|
7
|
+
discord_webhook: https://discord.com/api/webhooks/...
|
|
8
|
+
telegram_token: 123:ABC
|
|
9
|
+
telegram_chat_id: "123456"
|
|
10
|
+
|
|
11
|
+
Used by the loop (on task completion) and by scheduled agents.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def send_slack(webhook: str, text: str) -> bool:
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
r = httpx.post(webhook, json={"text": text}, timeout=15)
|
|
23
|
+
return r.status_code < 300
|
|
24
|
+
except Exception:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def send_discord(webhook: str, text: str) -> bool:
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
r = httpx.post(webhook, json={"content": text[:1900]}, timeout=15)
|
|
33
|
+
return r.status_code < 300
|
|
34
|
+
except Exception:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def send_telegram(token: str, chat_id: str, text: str) -> bool:
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
r = httpx.post(
|
|
43
|
+
f"https://api.telegram.org/bot{token}/sendMessage",
|
|
44
|
+
json={"chat_id": chat_id, "text": text[:4000]},
|
|
45
|
+
timeout=15,
|
|
46
|
+
)
|
|
47
|
+
return r.status_code < 300
|
|
48
|
+
except Exception:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def send_google_chat(webhook: str, text: str) -> bool:
|
|
53
|
+
import httpx
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
r = httpx.post(webhook, json={"text": text[:4000]}, timeout=15)
|
|
57
|
+
return r.status_code < 300
|
|
58
|
+
except Exception:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def send_whatsapp(sid: str, token: str, frm: str, to: str, text: str) -> bool:
|
|
63
|
+
"""Send a WhatsApp message via Twilio."""
|
|
64
|
+
import httpx
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
r = httpx.post(
|
|
68
|
+
f"https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json",
|
|
69
|
+
data={"From": f"whatsapp:{frm}", "To": f"whatsapp:{to}", "Body": text[:1500]},
|
|
70
|
+
auth=(sid, token),
|
|
71
|
+
timeout=15,
|
|
72
|
+
)
|
|
73
|
+
return r.status_code < 300
|
|
74
|
+
except Exception:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def send_linear(api_key: str, team_id: str, text: str) -> bool:
|
|
79
|
+
"""Create a Linear issue with the result."""
|
|
80
|
+
import httpx
|
|
81
|
+
|
|
82
|
+
mutation = (
|
|
83
|
+
"mutation($t:String!,$d:String!,$team:String!){"
|
|
84
|
+
"issueCreate(input:{teamId:$team,title:$t,description:$d}){success}}"
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
r = httpx.post(
|
|
88
|
+
"https://api.linear.app/graphql",
|
|
89
|
+
json={"query": mutation, "variables": {
|
|
90
|
+
"t": text.splitlines()[0][:80] or "Krnl Agent", "d": text[:4000], "team": team_id}},
|
|
91
|
+
headers={"Authorization": api_key, "Content-Type": "application/json"},
|
|
92
|
+
timeout=15,
|
|
93
|
+
)
|
|
94
|
+
return r.status_code < 300
|
|
95
|
+
except Exception:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def dispatch(config: Optional[dict], text: str) -> list[str]:
|
|
100
|
+
"""Send `text` to every configured channel. Returns the channels notified."""
|
|
101
|
+
config = config or {}
|
|
102
|
+
sent = []
|
|
103
|
+
if config.get("slack_webhook") and send_slack(config["slack_webhook"], text):
|
|
104
|
+
sent.append("slack")
|
|
105
|
+
if config.get("discord_webhook") and send_discord(config["discord_webhook"], text):
|
|
106
|
+
sent.append("discord")
|
|
107
|
+
if config.get("telegram_token") and config.get("telegram_chat_id"):
|
|
108
|
+
if send_telegram(config["telegram_token"], str(config["telegram_chat_id"]), text):
|
|
109
|
+
sent.append("telegram")
|
|
110
|
+
if config.get("google_chat_webhook") and send_google_chat(config["google_chat_webhook"], text):
|
|
111
|
+
sent.append("google_chat")
|
|
112
|
+
if all(config.get(k) for k in ("twilio_sid", "twilio_token", "whatsapp_from", "whatsapp_to")):
|
|
113
|
+
if send_whatsapp(config["twilio_sid"], config["twilio_token"],
|
|
114
|
+
str(config["whatsapp_from"]), str(config["whatsapp_to"]), text):
|
|
115
|
+
sent.append("whatsapp")
|
|
116
|
+
if config.get("linear_api_key") and config.get("linear_team_id"):
|
|
117
|
+
if send_linear(config["linear_api_key"], config["linear_team_id"], text):
|
|
118
|
+
sent.append("linear")
|
|
119
|
+
return sent
|