halyn 0.2.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.
- halyn/__init__.py +7 -0
- halyn/__main__.py +4 -0
- halyn/audit.py +278 -0
- halyn/auth.py +88 -0
- halyn/autonomy.py +262 -0
- halyn/cli.py +208 -0
- halyn/config.py +135 -0
- halyn/consent.py +243 -0
- halyn/control_plane.py +354 -0
- halyn/discovery.py +323 -0
- halyn/drivers/__init__.py +0 -0
- halyn/drivers/browser.py +60 -0
- halyn/drivers/dds.py +156 -0
- halyn/drivers/docker.py +62 -0
- halyn/drivers/http_auto.py +259 -0
- halyn/drivers/mqtt.py +93 -0
- halyn/drivers/opcua.py +77 -0
- halyn/drivers/ros2.py +124 -0
- halyn/drivers/serial.py +226 -0
- halyn/drivers/socket_raw.py +153 -0
- halyn/drivers/ssh.py +131 -0
- halyn/drivers/unitree.py +103 -0
- halyn/drivers/websocket.py +175 -0
- halyn/engine.py +222 -0
- halyn/intent.py +240 -0
- halyn/llm.py +178 -0
- halyn/mcp.py +239 -0
- halyn/memory/__init__.py +0 -0
- halyn/memory/store.py +200 -0
- halyn/nrp_bridge.py +213 -0
- halyn/py.typed +0 -0
- halyn/sanitizer.py +120 -0
- halyn/server.py +292 -0
- halyn/types.py +116 -0
- halyn/watchdog.py +252 -0
- halyn-0.2.0.dist-info/METADATA +246 -0
- halyn-0.2.0.dist-info/RECORD +41 -0
- halyn-0.2.0.dist-info/WHEEL +5 -0
- halyn-0.2.0.dist-info/entry_points.txt +2 -0
- halyn-0.2.0.dist-info/licenses/LICENSE +15 -0
- halyn-0.2.0.dist-info/top_level.txt +1 -0
halyn/intent.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Intent Chain — Structured provenance for every executed action.
|
|
5
|
+
|
|
6
|
+
Every action has a chain:
|
|
7
|
+
HUMAN REQUEST → AI REASONING → PLAN → ACTION → RESULT
|
|
8
|
+
|
|
9
|
+
Structured provenance with persistent storage.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import sqlite3
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("halyn.intent")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class IntentStep:
|
|
32
|
+
"""One step in the intent chain."""
|
|
33
|
+
step_type: str # "request", "reasoning", "plan", "action", "result", "shield_check"
|
|
34
|
+
content: str # Human-readable description
|
|
35
|
+
timestamp: float = field(default_factory=time.time)
|
|
36
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"step_type": self.step_type,
|
|
41
|
+
"content": self.content,
|
|
42
|
+
"timestamp": self.timestamp,
|
|
43
|
+
"metadata": self.metadata,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class IntentChain:
|
|
49
|
+
"""Complete chain for one action — from human request to result."""
|
|
50
|
+
chain_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
51
|
+
user_id: str = ""
|
|
52
|
+
llm_model: str = ""
|
|
53
|
+
node: str = ""
|
|
54
|
+
domain: str = ""
|
|
55
|
+
autonomy_level: int = -1
|
|
56
|
+
steps: list[IntentStep] = field(default_factory=list)
|
|
57
|
+
created_at: float = field(default_factory=time.time)
|
|
58
|
+
completed_at: float = 0.0
|
|
59
|
+
status: str = "in_progress" # in_progress, completed, failed, blocked
|
|
60
|
+
|
|
61
|
+
def add(self, step_type: str, content: str, **metadata: Any) -> IntentStep:
|
|
62
|
+
step = IntentStep(step_type=step_type, content=content, metadata=metadata)
|
|
63
|
+
self.steps.append(step)
|
|
64
|
+
return step
|
|
65
|
+
|
|
66
|
+
def request(self, content: str, **meta: Any) -> IntentStep:
|
|
67
|
+
return self.add("request", content, **meta)
|
|
68
|
+
|
|
69
|
+
def reasoning(self, content: str, **meta: Any) -> IntentStep:
|
|
70
|
+
return self.add("reasoning", content, **meta)
|
|
71
|
+
|
|
72
|
+
def plan(self, content: str, **meta: Any) -> IntentStep:
|
|
73
|
+
return self.add("plan", content, **meta)
|
|
74
|
+
|
|
75
|
+
def shield_check(self, content: str, passed: bool = True, **meta: Any) -> IntentStep:
|
|
76
|
+
return self.add("shield_check", content, passed=passed, **meta)
|
|
77
|
+
|
|
78
|
+
def action(self, content: str, tool: str = "", **meta: Any) -> IntentStep:
|
|
79
|
+
return self.add("action", content, tool=tool, **meta)
|
|
80
|
+
|
|
81
|
+
def result(self, content: str, success: bool = True, **meta: Any) -> IntentStep:
|
|
82
|
+
self.completed_at = time.time()
|
|
83
|
+
self.status = "completed" if success else "failed"
|
|
84
|
+
return self.add("result", content, success=success, **meta)
|
|
85
|
+
|
|
86
|
+
def blocked(self, content: str, **meta: Any) -> IntentStep:
|
|
87
|
+
self.completed_at = time.time()
|
|
88
|
+
self.status = "blocked"
|
|
89
|
+
return self.add("blocked", content, **meta)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def duration_ms(self) -> float:
|
|
93
|
+
if self.completed_at:
|
|
94
|
+
return (self.completed_at - self.created_at) * 1000
|
|
95
|
+
return (time.time() - self.created_at) * 1000
|
|
96
|
+
|
|
97
|
+
def summary(self) -> str:
|
|
98
|
+
"""One-line summary for logs."""
|
|
99
|
+
req = next((s.content for s in self.steps if s.step_type == "request"), "?")
|
|
100
|
+
res = next((s.content for s in reversed(self.steps) if s.step_type == "result"), "?")
|
|
101
|
+
return f"[{self.status}] {req[:60]} → {res[:60]}"
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict[str, Any]:
|
|
104
|
+
return {
|
|
105
|
+
"chain_id": self.chain_id,
|
|
106
|
+
"user_id": self.user_id,
|
|
107
|
+
"llm_model": self.llm_model,
|
|
108
|
+
"node": self.node,
|
|
109
|
+
"domain": self.domain,
|
|
110
|
+
"autonomy_level": self.autonomy_level,
|
|
111
|
+
"steps": [s.to_dict() for s in self.steps],
|
|
112
|
+
"created_at": self.created_at,
|
|
113
|
+
"completed_at": self.completed_at,
|
|
114
|
+
"status": self.status,
|
|
115
|
+
"duration_ms": round(self.duration_ms, 2),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def to_json(self, indent: int = 2) -> str:
|
|
119
|
+
return json.dumps(self.to_dict(), indent=indent, default=str)
|
|
120
|
+
|
|
121
|
+
def to_readable(self) -> str:
|
|
122
|
+
"""Human-readable chain for display."""
|
|
123
|
+
lines = [
|
|
124
|
+
f"Intent Chain: {self.chain_id}",
|
|
125
|
+
f" Status: {self.status} ({self.duration_ms:.0f}ms)",
|
|
126
|
+
f" Node: {self.node}",
|
|
127
|
+
f" Domain: {self.domain} (level {self.autonomy_level})",
|
|
128
|
+
f" LLM: {self.llm_model}",
|
|
129
|
+
f" User: {self.user_id}",
|
|
130
|
+
"",
|
|
131
|
+
]
|
|
132
|
+
for i, step in enumerate(self.steps):
|
|
133
|
+
icon = {
|
|
134
|
+
"request": "📋", "reasoning": "🧠", "plan": "📝",
|
|
135
|
+
"shield_check": "🛡", "action": "⚡", "result": "✅",
|
|
136
|
+
"blocked": "🚫",
|
|
137
|
+
}.get(step.step_type, "•")
|
|
138
|
+
lines.append(f" {icon} [{step.step_type}] {step.content}")
|
|
139
|
+
if step.metadata:
|
|
140
|
+
for k, v in step.metadata.items():
|
|
141
|
+
lines.append(f" {k}: {v}")
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_CHAIN_SCHEMA = """
|
|
146
|
+
CREATE TABLE IF NOT EXISTS intent_chains (
|
|
147
|
+
chain_id TEXT PRIMARY KEY,
|
|
148
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
149
|
+
llm_model TEXT NOT NULL DEFAULT '',
|
|
150
|
+
node TEXT NOT NULL DEFAULT '',
|
|
151
|
+
domain TEXT NOT NULL DEFAULT '',
|
|
152
|
+
autonomy_level INTEGER NOT NULL DEFAULT -1,
|
|
153
|
+
steps TEXT NOT NULL DEFAULT '[]',
|
|
154
|
+
created_at REAL NOT NULL,
|
|
155
|
+
completed_at REAL NOT NULL DEFAULT 0,
|
|
156
|
+
status TEXT NOT NULL DEFAULT 'in_progress'
|
|
157
|
+
);
|
|
158
|
+
CREATE INDEX IF NOT EXISTS idx_intent_node ON intent_chains(node);
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_intent_status ON intent_chains(status);
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_intent_created ON intent_chains(created_at);
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class IntentStore:
|
|
165
|
+
"""Persistent storage for intent chains."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, db_path: str = "") -> None:
|
|
168
|
+
if not db_path:
|
|
169
|
+
data_dir = Path.home() / ".halyn"
|
|
170
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
db_path = str(data_dir / "intent.db")
|
|
172
|
+
self._lock = threading.Lock()
|
|
173
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
174
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
175
|
+
self._conn.executescript(_CHAIN_SCHEMA)
|
|
176
|
+
self._conn.commit()
|
|
177
|
+
|
|
178
|
+
def save(self, chain: IntentChain) -> None:
|
|
179
|
+
with self._lock:
|
|
180
|
+
self._conn.execute(
|
|
181
|
+
"INSERT OR REPLACE INTO intent_chains "
|
|
182
|
+
"(chain_id, user_id, llm_model, node, domain, autonomy_level, "
|
|
183
|
+
"steps, created_at, completed_at, status) "
|
|
184
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
185
|
+
(chain.chain_id, chain.user_id, chain.llm_model,
|
|
186
|
+
chain.node, chain.domain, chain.autonomy_level,
|
|
187
|
+
json.dumps([s.to_dict() for s in chain.steps], default=str),
|
|
188
|
+
chain.created_at, chain.completed_at, chain.status),
|
|
189
|
+
)
|
|
190
|
+
self._conn.commit()
|
|
191
|
+
|
|
192
|
+
def get(self, chain_id: str) -> IntentChain | None:
|
|
193
|
+
with self._lock:
|
|
194
|
+
row = self._conn.execute(
|
|
195
|
+
"SELECT * FROM intent_chains WHERE chain_id = ?", (chain_id,)
|
|
196
|
+
).fetchone()
|
|
197
|
+
if not row:
|
|
198
|
+
return None
|
|
199
|
+
return self._row_to_chain(row)
|
|
200
|
+
|
|
201
|
+
def query(self, node: str = "", status: str = "", limit: int = 50) -> list[IntentChain]:
|
|
202
|
+
conditions, params = [], []
|
|
203
|
+
if node:
|
|
204
|
+
conditions.append("node LIKE ?")
|
|
205
|
+
params.append(f"%{node}%")
|
|
206
|
+
if status:
|
|
207
|
+
conditions.append("status = ?")
|
|
208
|
+
params.append(status)
|
|
209
|
+
where = " AND ".join(conditions) if conditions else "1=1"
|
|
210
|
+
params.append(limit)
|
|
211
|
+
with self._lock:
|
|
212
|
+
rows = self._conn.execute(
|
|
213
|
+
f"SELECT * FROM intent_chains WHERE {where} ORDER BY created_at DESC LIMIT ?",
|
|
214
|
+
params,
|
|
215
|
+
).fetchall()
|
|
216
|
+
return [self._row_to_chain(r) for r in rows]
|
|
217
|
+
|
|
218
|
+
def export_jsonl(self, path: str) -> int:
|
|
219
|
+
chains = self.query(limit=100_000)
|
|
220
|
+
with open(path, "w") as f:
|
|
221
|
+
for c in reversed(chains):
|
|
222
|
+
f.write(c.to_json() + "\n")
|
|
223
|
+
return len(chains)
|
|
224
|
+
|
|
225
|
+
def _row_to_chain(self, row: tuple) -> IntentChain:
|
|
226
|
+
steps_data = json.loads(row[6]) if row[6] else []
|
|
227
|
+
steps = [IntentStep(
|
|
228
|
+
step_type=s["step_type"], content=s["content"],
|
|
229
|
+
timestamp=s.get("timestamp", 0),
|
|
230
|
+
metadata=s.get("metadata", {}),
|
|
231
|
+
) for s in steps_data]
|
|
232
|
+
return IntentChain(
|
|
233
|
+
chain_id=row[0], user_id=row[1], llm_model=row[2],
|
|
234
|
+
node=row[3], domain=row[4], autonomy_level=row[5],
|
|
235
|
+
steps=steps, created_at=row[7], completed_at=row[8], status=row[9],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def close(self) -> None:
|
|
239
|
+
with self._lock:
|
|
240
|
+
self._conn.close()
|
halyn/llm.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
LLM Connector — Plug any brain into Jarvis.
|
|
5
|
+
|
|
6
|
+
Supports: Claude API, OpenAI API, Ollama (local), HuggingFace (local),
|
|
7
|
+
vLLM (self-hosted), any OpenAI-compatible endpoint.
|
|
8
|
+
|
|
9
|
+
The LLM is NOT in the control plane. It connects FROM OUTSIDE via MCP.
|
|
10
|
+
This module is for the REVERSE: when Jarvis needs to ASK the LLM
|
|
11
|
+
(e.g. for autonomous reasoning, incident analysis, summarization).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("jarvis.llm")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LLMConnector(ABC):
|
|
26
|
+
"""Base class for LLM connections."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def complete(self, prompt: str, system: str = "", max_tokens: int = 1000) -> str:
|
|
30
|
+
"""Send prompt, get response."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def is_available(self) -> bool:
|
|
34
|
+
"""Check if LLM is reachable."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClaudeConnector(LLMConnector):
|
|
38
|
+
"""Anthropic Claude API."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, api_key: str = "", model: str = "claude-sonnet-4-20250514") -> None:
|
|
41
|
+
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
42
|
+
self.model = model
|
|
43
|
+
self.endpoint = "https://api.anthropic.com/v1/messages"
|
|
44
|
+
|
|
45
|
+
async def complete(self, prompt: str, system: str = "", max_tokens: int = 1000) -> str:
|
|
46
|
+
import aiohttp
|
|
47
|
+
headers = {
|
|
48
|
+
"x-api-key": self.api_key,
|
|
49
|
+
"anthropic-version": "2023-06-01",
|
|
50
|
+
"content-type": "application/json",
|
|
51
|
+
}
|
|
52
|
+
body: dict[str, Any] = {
|
|
53
|
+
"model": self.model,
|
|
54
|
+
"max_tokens": max_tokens,
|
|
55
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
56
|
+
}
|
|
57
|
+
if system:
|
|
58
|
+
body["system"] = system
|
|
59
|
+
async with aiohttp.ClientSession() as session:
|
|
60
|
+
async with session.post(self.endpoint, json=body, headers=headers) as resp:
|
|
61
|
+
data = await resp.json()
|
|
62
|
+
return data.get("content", [{}])[0].get("text", "")
|
|
63
|
+
|
|
64
|
+
async def is_available(self) -> bool:
|
|
65
|
+
return bool(self.api_key)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class OpenAIConnector(LLMConnector):
|
|
69
|
+
"""OpenAI or any OpenAI-compatible API (vLLM, LiteLLM, etc.)."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, api_key: str = "", model: str = "gpt-4o",
|
|
72
|
+
endpoint: str = "https://api.openai.com/v1") -> None:
|
|
73
|
+
self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
|
|
74
|
+
self.model = model
|
|
75
|
+
self.endpoint = endpoint
|
|
76
|
+
|
|
77
|
+
async def complete(self, prompt: str, system: str = "", max_tokens: int = 1000) -> str:
|
|
78
|
+
import aiohttp
|
|
79
|
+
headers = {
|
|
80
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
}
|
|
83
|
+
messages = []
|
|
84
|
+
if system:
|
|
85
|
+
messages.append({"role": "system", "content": system})
|
|
86
|
+
messages.append({"role": "user", "content": prompt})
|
|
87
|
+
body = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
|
|
88
|
+
async with aiohttp.ClientSession() as session:
|
|
89
|
+
async with session.post(f"{self.endpoint}/chat/completions",
|
|
90
|
+
json=body, headers=headers) as resp:
|
|
91
|
+
data = await resp.json()
|
|
92
|
+
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
93
|
+
|
|
94
|
+
async def is_available(self) -> bool:
|
|
95
|
+
return bool(self.api_key)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OllamaConnector(LLMConnector):
|
|
99
|
+
"""Ollama local inference. Zero cost, zero internet."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, model: str = "llama3.2", host: str = "http://localhost:11434") -> None:
|
|
102
|
+
self.model = model
|
|
103
|
+
self.host = host
|
|
104
|
+
|
|
105
|
+
async def complete(self, prompt: str, system: str = "", max_tokens: int = 1000) -> str:
|
|
106
|
+
import aiohttp
|
|
107
|
+
body: dict[str, Any] = {
|
|
108
|
+
"model": self.model,
|
|
109
|
+
"prompt": prompt,
|
|
110
|
+
"stream": False,
|
|
111
|
+
}
|
|
112
|
+
if system:
|
|
113
|
+
body["system"] = system
|
|
114
|
+
async with aiohttp.ClientSession() as session:
|
|
115
|
+
async with session.post(f"{self.host}/api/generate", json=body) as resp:
|
|
116
|
+
data = await resp.json()
|
|
117
|
+
return data.get("response", "")
|
|
118
|
+
|
|
119
|
+
async def is_available(self) -> bool:
|
|
120
|
+
try:
|
|
121
|
+
import aiohttp
|
|
122
|
+
async with aiohttp.ClientSession() as session:
|
|
123
|
+
async with session.get(f"{self.host}/api/tags", timeout=aiohttp.ClientTimeout(total=3)) as resp:
|
|
124
|
+
return resp.status == 200
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class HuggingFaceConnector(LLMConnector):
|
|
130
|
+
"""Run any HuggingFace model locally. Zero cloud."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, model: str = "mistralai/Mistral-7B-Instruct-v0.3") -> None:
|
|
133
|
+
self.model_name = model
|
|
134
|
+
self._pipeline: Any = None
|
|
135
|
+
|
|
136
|
+
async def complete(self, prompt: str, system: str = "", max_tokens: int = 1000) -> str:
|
|
137
|
+
if self._pipeline is None:
|
|
138
|
+
self._load()
|
|
139
|
+
full_prompt = f"{system}
|
|
140
|
+
|
|
141
|
+
{prompt}" if system else prompt
|
|
142
|
+
result = self._pipeline(full_prompt, max_new_tokens=max_tokens, do_sample=True, temperature=0.7)
|
|
143
|
+
return result[0]["generated_text"][len(full_prompt):]
|
|
144
|
+
|
|
145
|
+
async def is_available(self) -> bool:
|
|
146
|
+
try:
|
|
147
|
+
import transformers # noqa: F401
|
|
148
|
+
return True
|
|
149
|
+
except ImportError:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def _load(self) -> None:
|
|
153
|
+
from transformers import pipeline
|
|
154
|
+
log.info("llm.loading model=%s (this may take a while...)", self.model_name)
|
|
155
|
+
self._pipeline = pipeline("text-generation", model=self.model_name, device_map="auto")
|
|
156
|
+
log.info("llm.loaded model=%s", self.model_name)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ─── Factory ────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def create_connector(provider: str, **kwargs: Any) -> LLMConnector:
|
|
162
|
+
"""Create an LLM connector by name."""
|
|
163
|
+
connectors: dict[str, type[LLMConnector]] = {
|
|
164
|
+
"claude": ClaudeConnector,
|
|
165
|
+
"anthropic": ClaudeConnector,
|
|
166
|
+
"openai": OpenAIConnector,
|
|
167
|
+
"gpt": OpenAIConnector,
|
|
168
|
+
"ollama": OllamaConnector,
|
|
169
|
+
"huggingface": HuggingFaceConnector,
|
|
170
|
+
"hf": HuggingFaceConnector,
|
|
171
|
+
"vllm": OpenAIConnector, # vLLM is OpenAI-compatible
|
|
172
|
+
"litellm": OpenAIConnector,
|
|
173
|
+
}
|
|
174
|
+
cls = connectors.get(provider.lower())
|
|
175
|
+
if cls is None:
|
|
176
|
+
raise ValueError(f"Unknown LLM provider: {provider}. Available: {list(connectors.keys())}")
|
|
177
|
+
return cls(**kwargs)
|
|
178
|
+
|
halyn/mcp.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
MCP Server — Expose Halyn tools to Claude.ai natively.
|
|
5
|
+
|
|
6
|
+
When connected via MCP, Claude sees all NRP nodes as tools.
|
|
7
|
+
Observe, act, shield, scan — directly in the conversation.
|
|
8
|
+
|
|
9
|
+
MCP JSON-RPC spec: https://spec.modelcontextprotocol.io
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("halyn.mcp")
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from aiohttp import web
|
|
22
|
+
HAS_AIOHTTP = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
HAS_AIOHTTP = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MCPServer:
|
|
28
|
+
"""
|
|
29
|
+
MCP JSON-RPC endpoint.
|
|
30
|
+
|
|
31
|
+
Generates tools dynamically from ControlPlane state:
|
|
32
|
+
- Every NRP node creates observe/act/info tools
|
|
33
|
+
- System tools: scan, emergency_stop, resume, status
|
|
34
|
+
- Consent tools: approve, deny, list_pending
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, control_plane: Any) -> None:
|
|
38
|
+
self.cp = control_plane
|
|
39
|
+
|
|
40
|
+
def get_tools(self) -> list[dict[str, Any]]:
|
|
41
|
+
"""Generate MCP tool definitions from current NRP nodes."""
|
|
42
|
+
tools: list[dict[str, Any]] = []
|
|
43
|
+
|
|
44
|
+
# System tools
|
|
45
|
+
tools.append({
|
|
46
|
+
"name": "halyn_status",
|
|
47
|
+
"description": "Get system status: nodes, tools, audit, watchdog health.",
|
|
48
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
49
|
+
})
|
|
50
|
+
tools.append({
|
|
51
|
+
"name": "halyn_scan",
|
|
52
|
+
"description": "Discover devices on the network. Returns found nodes with suggested NRP IDs.",
|
|
53
|
+
"inputSchema": {"type": "object", "properties": {
|
|
54
|
+
"ssh_hosts": {"type": "string", "description": "Comma-separated SSH hosts to probe"},
|
|
55
|
+
"http_urls": {"type": "string", "description": "Comma-separated HTTP URLs to check"},
|
|
56
|
+
}},
|
|
57
|
+
})
|
|
58
|
+
tools.append({
|
|
59
|
+
"name": "halyn_emergency_stop",
|
|
60
|
+
"description": "STOP ALL NODES IMMEDIATELY. Use only in emergencies.",
|
|
61
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
62
|
+
})
|
|
63
|
+
tools.append({
|
|
64
|
+
"name": "halyn_resume",
|
|
65
|
+
"description": "Resume operations after emergency stop.",
|
|
66
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
67
|
+
})
|
|
68
|
+
tools.append({
|
|
69
|
+
"name": "halyn_audit",
|
|
70
|
+
"description": "Query the audit trail. Returns recent actions with hash chain verification.",
|
|
71
|
+
"inputSchema": {"type": "object", "properties": {
|
|
72
|
+
"tool": {"type": "string", "description": "Filter by tool name"},
|
|
73
|
+
"node": {"type": "string", "description": "Filter by node"},
|
|
74
|
+
"limit": {"type": "integer", "description": "Max entries (default 20)"},
|
|
75
|
+
}},
|
|
76
|
+
})
|
|
77
|
+
tools.append({
|
|
78
|
+
"name": "halyn_consent_pending",
|
|
79
|
+
"description": "List nodes waiting for operator approval.",
|
|
80
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
# Node-specific tools from registry
|
|
84
|
+
for tool_name in sorted(self.cp.engine.registry.tool_names):
|
|
85
|
+
spec = self.cp.engine.registry.get_spec(tool_name)
|
|
86
|
+
if not spec:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
props: dict[str, Any] = {}
|
|
90
|
+
required: list[str] = []
|
|
91
|
+
|
|
92
|
+
if ".observe" in tool_name:
|
|
93
|
+
props["channels"] = {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "Comma-separated channel names to read (empty = all)",
|
|
96
|
+
}
|
|
97
|
+
elif ".act" in tool_name or ".shell" in tool_name:
|
|
98
|
+
props["command"] = {"type": "string", "description": "Command to execute"}
|
|
99
|
+
required.append("command")
|
|
100
|
+
elif any(tool_name.endswith(f".{a}") for a in (
|
|
101
|
+
"file_read", "file_write", "file_list", "log_tail",
|
|
102
|
+
"git_status", "service_restart", "process_list",
|
|
103
|
+
"calibrate", "set_threshold", "walk", "pick", "stand",
|
|
104
|
+
)):
|
|
105
|
+
# Action-specific tools get generic args
|
|
106
|
+
props["args"] = {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"description": "Action arguments",
|
|
109
|
+
"additionalProperties": True,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
tools.append({
|
|
113
|
+
"name": tool_name.replace("/", "__").replace(".", "_"),
|
|
114
|
+
"description": spec.description or tool_name,
|
|
115
|
+
"inputSchema": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"properties": props,
|
|
118
|
+
"required": required,
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return tools
|
|
123
|
+
|
|
124
|
+
async def handle_jsonrpc(self, request: "web.Request") -> "web.Response":
|
|
125
|
+
"""Handle MCP JSON-RPC requests."""
|
|
126
|
+
try:
|
|
127
|
+
body = await request.json()
|
|
128
|
+
except Exception:
|
|
129
|
+
return _mcp_error(-32700, "Parse error", None)
|
|
130
|
+
|
|
131
|
+
method = body.get("method", "")
|
|
132
|
+
params = body.get("params", {})
|
|
133
|
+
req_id = body.get("id")
|
|
134
|
+
|
|
135
|
+
if method == "initialize":
|
|
136
|
+
return _mcp_result(req_id, {
|
|
137
|
+
"protocolVersion": "2024-11-05",
|
|
138
|
+
"capabilities": {"tools": {"listChanged": True}},
|
|
139
|
+
"serverInfo": {"name": "halyn", "version": "0.1.0"},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
if method == "tools/list":
|
|
143
|
+
return _mcp_result(req_id, {"tools": self.get_tools()})
|
|
144
|
+
|
|
145
|
+
if method == "tools/call":
|
|
146
|
+
tool_name = params.get("name", "")
|
|
147
|
+
arguments = params.get("arguments", {})
|
|
148
|
+
result = await self._dispatch(tool_name, arguments)
|
|
149
|
+
return _mcp_result(req_id, {
|
|
150
|
+
"content": [{"type": "text", "text": json.dumps(result, default=str)}],
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if method == "notifications/initialized":
|
|
154
|
+
return web.Response(status=204)
|
|
155
|
+
|
|
156
|
+
return _mcp_error(-32601, f"Method not found: {method}", req_id)
|
|
157
|
+
|
|
158
|
+
async def _dispatch(self, mcp_name: str, args: dict[str, Any]) -> Any:
|
|
159
|
+
"""Route MCP tool call to ControlPlane."""
|
|
160
|
+
# System tools
|
|
161
|
+
if mcp_name == "halyn_status":
|
|
162
|
+
return self.cp.status()
|
|
163
|
+
|
|
164
|
+
if mcp_name == "halyn_scan":
|
|
165
|
+
config = {}
|
|
166
|
+
if args.get("ssh_hosts"):
|
|
167
|
+
config["ssh_hosts"] = [h.strip() for h in args["ssh_hosts"].split(",")]
|
|
168
|
+
if args.get("http_urls"):
|
|
169
|
+
config["http_urls"] = [u.strip() for u in args["http_urls"].split(",")]
|
|
170
|
+
nodes = await self.cp.scan(config or None)
|
|
171
|
+
return [{"address": n.address, "port": n.port, "protocol": n.protocol,
|
|
172
|
+
"name": n.name, "nrp_id": n.suggested_nrp_id} for n in nodes]
|
|
173
|
+
|
|
174
|
+
if mcp_name == "halyn_emergency_stop":
|
|
175
|
+
await self.cp.emergency_stop()
|
|
176
|
+
return {"status": "stopped"}
|
|
177
|
+
|
|
178
|
+
if mcp_name == "halyn_resume":
|
|
179
|
+
await self.cp.resume()
|
|
180
|
+
return {"status": "resumed"}
|
|
181
|
+
|
|
182
|
+
if mcp_name == "halyn_audit":
|
|
183
|
+
entries = self.cp.audit.query(
|
|
184
|
+
tool=args.get("tool", ""),
|
|
185
|
+
node=args.get("node", ""),
|
|
186
|
+
limit=int(args.get("limit", 20)),
|
|
187
|
+
)
|
|
188
|
+
valid, count, msg = self.cp.audit.verify_chain()
|
|
189
|
+
return {
|
|
190
|
+
"entries": [e.to_dict() for e in entries],
|
|
191
|
+
"chain_valid": valid,
|
|
192
|
+
"total": count,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if mcp_name == "halyn_consent_pending":
|
|
196
|
+
from .consent import ConsentLevel
|
|
197
|
+
pending = self.cp.consent.list_all(level=ConsentLevel.PENDING)
|
|
198
|
+
return [r.to_dict() for r in pending]
|
|
199
|
+
|
|
200
|
+
# Node tools: convert MCP name back to engine tool name
|
|
201
|
+
engine_name = mcp_name.replace("__", "/").replace("_", ".", 1)
|
|
202
|
+
# Try direct lookup first
|
|
203
|
+
if engine_name not in self.cp.engine.registry.tool_names:
|
|
204
|
+
# Try more aggressive conversion
|
|
205
|
+
parts = mcp_name.split("__")
|
|
206
|
+
if len(parts) == 2:
|
|
207
|
+
engine_name = parts[0] + "/" + parts[1].replace("_", ".", 1)
|
|
208
|
+
|
|
209
|
+
if engine_name in self.cp.engine.registry.tool_names:
|
|
210
|
+
result = await self.cp.execute(
|
|
211
|
+
engine_name, args,
|
|
212
|
+
llm_model="mcp",
|
|
213
|
+
intent_text=f"MCP call: {mcp_name}",
|
|
214
|
+
)
|
|
215
|
+
return {"ok": result.ok, "data": result.data, "error": result.error}
|
|
216
|
+
|
|
217
|
+
return {"error": f"unknown tool: {mcp_name}", "available": sorted(self.cp.engine.registry.tool_names)[:20]}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _mcp_result(req_id: Any, result: Any) -> "web.Response":
|
|
221
|
+
return web.Response(
|
|
222
|
+
text=json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result}, default=str),
|
|
223
|
+
content_type="application/json",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _mcp_error(code: int, message: str, req_id: Any) -> "web.Response":
|
|
228
|
+
return web.Response(
|
|
229
|
+
text=json.dumps({"jsonrpc": "2.0", "id": req_id,
|
|
230
|
+
"error": {"code": code, "message": message}}),
|
|
231
|
+
content_type="application/json",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def mount_mcp(app: "web.Application", control_plane: Any) -> None:
|
|
236
|
+
"""Mount the MCP endpoint on an existing aiohttp app."""
|
|
237
|
+
mcp = MCPServer(control_plane)
|
|
238
|
+
app.router.add_post("/mcp", mcp.handle_jsonrpc)
|
|
239
|
+
log.info("mcp.mounted path=/mcp")
|
halyn/memory/__init__.py
ADDED
|
File without changes
|