Add Calibrate AI Control UI and fleet LLM backend wiring.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators toggle Logic gates vs AI Control on Settings, refresh local Ollama models, and save ai_endpoint settings via Calibrate PUT; server scheduler and agent snapshot/command paths support stateless 60s fleet decisions.
This commit is contained in:
@@ -81,6 +81,16 @@ type ServerSettings struct {
|
||||
TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"`
|
||||
// AdaptiveStrategyEnabled learns LOTL tier order from fleet outcomes (user machines only).
|
||||
AdaptiveStrategyEnabled bool `json:"adaptive_strategy_enabled"`
|
||||
// AIControlEnabled switches fleet control from adaptive tier learning to local LLM decisions.
|
||||
AIControlEnabled bool `json:"ai_control_enabled"`
|
||||
// AIEndpoint is the OpenAI-compatible base URL (e.g. Ollama /v1).
|
||||
AIEndpoint string `json:"ai_endpoint"`
|
||||
// AIModel is the LLM model name for fleet AI control (Calibrate).
|
||||
AIModel string `json:"ai_model"`
|
||||
// AINoContext forces stateless single-turn decisions (no conversation memory).
|
||||
AINoContext bool `json:"ai_no_context"`
|
||||
// AIDecisionIntervalSec is seconds between AI decision cycles per agent (default 60).
|
||||
AIDecisionIntervalSec int `json:"ai_decision_interval_sec"`
|
||||
}
|
||||
|
||||
// WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread.
|
||||
@@ -283,6 +293,11 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
ServiceDeployAllowlist: defaultServiceDeployAllowlist(),
|
||||
AdaptiveStrategyEnabled: true,
|
||||
AIControlEnabled: false,
|
||||
AIEndpoint: "http://127.0.0.1:11434/v1",
|
||||
AIModel: "",
|
||||
AINoContext: true,
|
||||
AIDecisionIntervalSec: 60,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -321,6 +336,7 @@ func LoadConfig() *Config {
|
||||
var presentKeys map[string]json.RawMessage
|
||||
_ = json.Unmarshal(data, &presentKeys)
|
||||
mergeConfigExplicit(cfg, &fileCfg, presentKeys)
|
||||
hydrateLegacyAIConfig(cfg, data)
|
||||
if !strings.Contains(string(data), `"open_firewall_on_start"`) {
|
||||
cfg.Server.OpenFirewallOnStart = true
|
||||
}
|
||||
@@ -941,6 +957,30 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
||||
if in(srvKeys, "public_builds_latest_n") && src.Server.PublicBuildsLatestN != 0 {
|
||||
dst.Server.PublicBuildsLatestN = src.Server.PublicBuildsLatestN
|
||||
}
|
||||
if in(srvKeys, "adaptive_strategy_enabled") {
|
||||
dst.Server.AdaptiveStrategyEnabled = src.Server.AdaptiveStrategyEnabled
|
||||
}
|
||||
if in(srvKeys, "ai_control_enabled") {
|
||||
dst.Server.AIControlEnabled = src.Server.AIControlEnabled
|
||||
}
|
||||
if in(srvKeys, "ai_endpoint") {
|
||||
dst.Server.AIEndpoint = src.Server.AIEndpoint
|
||||
}
|
||||
if in(srvKeys, "ai_local_endpoint") && src.Server.AIEndpoint != "" {
|
||||
dst.Server.AIEndpoint = src.Server.AIEndpoint
|
||||
}
|
||||
if in(srvKeys, "ai_model") {
|
||||
dst.Server.AIModel = src.Server.AIModel
|
||||
}
|
||||
if in(srvKeys, "ai_no_context") {
|
||||
dst.Server.AINoContext = src.Server.AINoContext
|
||||
}
|
||||
if in(srvKeys, "ai_decision_interval_sec") && src.Server.AIDecisionIntervalSec > 0 {
|
||||
dst.Server.AIDecisionIntervalSec = src.Server.AIDecisionIntervalSec
|
||||
}
|
||||
if in(srvKeys, "ai_interval_sec") && src.Server.AIDecisionIntervalSec > 0 {
|
||||
dst.Server.AIDecisionIntervalSec = src.Server.AIDecisionIntervalSec
|
||||
}
|
||||
}
|
||||
|
||||
if has("tunnel_defaults") {
|
||||
@@ -969,6 +1009,36 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
func hydrateLegacyAIConfig(cfg *Config, raw []byte) {
|
||||
if cfg == nil || len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
return
|
||||
}
|
||||
srvRaw, ok := root["server"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var srv map[string]json.RawMessage
|
||||
if err := json.Unmarshal(srvRaw, &srv); err != nil {
|
||||
return
|
||||
}
|
||||
if ep, ok := srv["ai_local_endpoint"]; ok && cfg.Server.AIEndpoint == "" {
|
||||
var s string
|
||||
if json.Unmarshal(ep, &s) == nil && strings.TrimSpace(s) != "" {
|
||||
cfg.Server.AIEndpoint = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
if iv, ok := srv["ai_interval_sec"]; ok && cfg.Server.AIDecisionIntervalSec == 0 {
|
||||
var n int
|
||||
if json.Unmarshal(iv, &n) == nil && n > 0 {
|
||||
cfg.Server.AIDecisionIntervalSec = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
configPath := filepath.Join(c.DataDir, "config.json")
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
|
||||
143
server/internal/ai/client.go
Normal file
143
server/internal/ai/client.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var defaultHTTPClient = &http.Client{Timeout: 45 * time.Second}
|
||||
|
||||
// ListModels GET {endpoint}/models — OpenAI-compatible model list.
|
||||
func ListModels(ctx context.Context, endpoint string) ([]string, error) {
|
||||
return ListModelsWithClient(ctx, endpoint, defaultHTTPClient)
|
||||
}
|
||||
|
||||
func ListModelsWithClient(ctx context.Context, endpoint string, client *http.Client) ([]string, error) {
|
||||
base := normalizeEndpoint(endpoint)
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("endpoint is required")
|
||||
}
|
||||
if client == nil {
|
||||
client = defaultHTTPClient
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/models", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("models: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("models: parse: %w", err)
|
||||
}
|
||||
names := make([]string, 0)
|
||||
seen := map[string]bool{}
|
||||
for _, m := range out.Data {
|
||||
id := strings.TrimSpace(m.ID)
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
names = append(names, id)
|
||||
}
|
||||
}
|
||||
for _, m := range out.Models {
|
||||
name := strings.TrimSpace(m.Name)
|
||||
if name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// Decide POST chat/completions — single turn, no conversation history.
|
||||
func Decide(ctx context.Context, endpoint, model, systemPrompt, userPrompt string) (string, error) {
|
||||
return DecideWithClient(ctx, endpoint, model, systemPrompt, userPrompt, defaultHTTPClient)
|
||||
}
|
||||
|
||||
func DecideWithClient(ctx context.Context, endpoint, model, systemPrompt, userPrompt string, client *http.Client) (string, error) {
|
||||
base := normalizeEndpoint(endpoint)
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("endpoint is required")
|
||||
}
|
||||
if client == nil {
|
||||
client = defaultHTTPClient
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "llama3.2"
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
"stream": false,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("completions: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var completion struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &completion); err != nil {
|
||||
return "", fmt.Errorf("completions: parse: %w", err)
|
||||
}
|
||||
if completion.Error != nil && completion.Error.Message != "" {
|
||||
return "", fmt.Errorf("completions: %s", completion.Error.Message)
|
||||
}
|
||||
if len(completion.Choices) == 0 {
|
||||
return "", fmt.Errorf("completions: empty choices")
|
||||
}
|
||||
return strings.TrimSpace(completion.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
func normalizeEndpoint(endpoint string) string {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
endpoint = strings.TrimRight(endpoint, "/")
|
||||
if endpoint == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasSuffix(endpoint, "/v1") {
|
||||
endpoint += "/v1"
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
66
server/internal/ai/client_test.go
Normal file
66
server/internal/ai/client_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListModelsOpenAI(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": []map[string]string{{"id": "llama3.2"}, {"id": "mistral"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := ListModelsWithClient(context.Background(), srv.URL+"/v1", srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(models) != 2 || models[0] != "llama3.2" {
|
||||
t.Fatalf("models: %v", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOpenAI(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var req map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
msgs, _ := req["messages"].([]interface{})
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected single-turn messages, got %d", len(msgs))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{
|
||||
{"message": map[string]string{"content": `{"commands":[{"type":"noop","args":{}}]}`}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, err := DecideWithClient(context.Background(), srv.URL+"/v1", "test-model", "sys", "user", srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmds := ParseCommands(out)
|
||||
if len(cmds) != 1 || cmds[0].Type != CmdNoop {
|
||||
t.Fatalf("parse: %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEndpoint(t *testing.T) {
|
||||
if got := normalizeEndpoint("http://127.0.0.1:11434"); got != "http://127.0.0.1:11434/v1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
169
server/internal/ai/commands.go
Normal file
169
server/internal/ai/commands.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
CmdBulkCommand = "bulk_command"
|
||||
CmdAgentCommand = "agent_command"
|
||||
CmdDiscoverAndJoin = "discover_and_join"
|
||||
CmdRestartMining = "restart_mining"
|
||||
CmdReorderTiers = "reorder_tiers"
|
||||
CmdSpreadNow = "spread_now"
|
||||
CmdStageFetch = "stage_fetch"
|
||||
CmdSetAgentVersion = "set_agent_version"
|
||||
CmdNoop = "noop"
|
||||
)
|
||||
|
||||
var knownCommands = map[string]bool{
|
||||
CmdBulkCommand: true,
|
||||
CmdAgentCommand: true,
|
||||
CmdDiscoverAndJoin: true,
|
||||
CmdRestartMining: true,
|
||||
CmdReorderTiers: true,
|
||||
CmdSpreadNow: true,
|
||||
CmdStageFetch: true,
|
||||
CmdSetAgentVersion: true,
|
||||
CmdNoop: true,
|
||||
}
|
||||
|
||||
var jsonBlockRe = regexp.MustCompile(`(?s)\{[\s\n]*"commands"\s*:\s*\[[\s\S]*?\]\s*\}`)
|
||||
|
||||
// ParseCommands extracts fleet commands from LLM text (JSON block, tool-call, or COMMAND: lines).
|
||||
func ParseCommands(raw string) []Command {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if cmds := parseCommandsJSON(raw); len(cmds) > 0 {
|
||||
return cmds
|
||||
}
|
||||
if block := jsonBlockRe.FindString(raw); block != "" && block != raw {
|
||||
if cmds := parseCommandsJSON(block); len(cmds) > 0 {
|
||||
return cmds
|
||||
}
|
||||
}
|
||||
if cmd := parseToolCall(raw); cmd != nil {
|
||||
return []Command{*cmd}
|
||||
}
|
||||
return parseCommandLines(raw)
|
||||
}
|
||||
|
||||
func parseCommandsJSON(raw string) []Command {
|
||||
var envelope struct {
|
||||
Commands []Command `json:"commands"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err == nil && len(envelope.Commands) > 0 {
|
||||
return normalizeCommands(envelope.Commands)
|
||||
}
|
||||
// Bare array
|
||||
var arr []Command
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err == nil && len(arr) > 0 {
|
||||
return normalizeCommands(arr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseToolCall(raw string) *Command {
|
||||
var tool struct {
|
||||
Tool string `json:"tool"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &tool); err != nil {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(tool.Tool)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(tool.Type)
|
||||
}
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
name = normalizeCommandType(name)
|
||||
if !knownCommands[name] && name != "restart_agent" {
|
||||
return &Command{Type: CmdAgentCommand, Args: map[string]interface{}{"action": name, "args": tool.Args}}
|
||||
}
|
||||
if name == "restart_agent" {
|
||||
name = CmdRestartMining
|
||||
}
|
||||
return &Command{Type: name, Args: tool.Args}
|
||||
}
|
||||
|
||||
func parseCommandLines(raw string) []Command {
|
||||
var out []Command
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(strings.ToUpper(line), "COMMAND:") {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimSpace(line[len("COMMAND:"):])
|
||||
if rest == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(rest)
|
||||
cmdType := normalizeCommandType(parts[0])
|
||||
args := map[string]interface{}{}
|
||||
for _, p := range parts[1:] {
|
||||
kv := strings.SplitN(p, "=", 2)
|
||||
if len(kv) == 2 {
|
||||
args[kv[0]] = kv[1]
|
||||
}
|
||||
}
|
||||
out = append(out, Command{Type: cmdType, Args: args})
|
||||
}
|
||||
return normalizeCommands(out)
|
||||
}
|
||||
|
||||
func normalizeCommands(cmds []Command) []Command {
|
||||
out := make([]Command, 0, len(cmds))
|
||||
for _, c := range cmds {
|
||||
typ := normalizeCommandType(c.Type)
|
||||
if typ == "" {
|
||||
continue
|
||||
}
|
||||
if typ == "restart_agent" {
|
||||
typ = CmdRestartMining
|
||||
}
|
||||
args := c.Args
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
out = append(out, Command{Type: typ, Args: args})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCommandType(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
s = strings.ReplaceAll(s, "-", "_")
|
||||
if s == "restart" {
|
||||
return CmdRestartMining
|
||||
}
|
||||
if knownCommands[s] {
|
||||
return s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// FormatExecuted summarizes commands for audit log storage.
|
||||
func FormatExecuted(cmds []Command, results []string) string {
|
||||
parts := make([]string, 0, len(cmds))
|
||||
for i, c := range cmds {
|
||||
msg := c.Type
|
||||
if i < len(results) && results[i] != "" {
|
||||
msg = fmt.Sprintf("%s:%s", c.Type, results[i])
|
||||
}
|
||||
parts = append(parts, msg)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
39
server/internal/ai/commands_test.go
Normal file
39
server/internal/ai/commands_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package ai
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCommandsJSONBlock(t *testing.T) {
|
||||
raw := `Here is my plan:
|
||||
{"commands":[{"type":"restart_mining","args":{}}]}`
|
||||
cmds := ParseCommands(raw)
|
||||
if len(cmds) != 1 || cmds[0].Type != CmdRestartMining {
|
||||
t.Fatalf("got %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandsToolCall(t *testing.T) {
|
||||
raw := `{"tool":"restart_agent","args":{}}`
|
||||
cmds := ParseCommands(raw)
|
||||
if len(cmds) != 1 || cmds[0].Type != CmdRestartMining {
|
||||
t.Fatalf("got %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandsLineFallback(t *testing.T) {
|
||||
raw := "COMMAND: spread_now\nCOMMAND: noop"
|
||||
cmds := ParseCommands(raw)
|
||||
if len(cmds) != 2 || cmds[0].Type != CmdSpreadNow || cmds[1].Type != CmdNoop {
|
||||
t.Fatalf("got %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandsAgentCommand(t *testing.T) {
|
||||
raw := `{"commands":[{"type":"agent_command","args":{"action":"pause"}}]}`
|
||||
cmds := ParseCommands(raw)
|
||||
if len(cmds) != 1 || cmds[0].Type != CmdAgentCommand {
|
||||
t.Fatalf("got %+v", cmds)
|
||||
}
|
||||
if cmds[0].Args["action"] != "pause" {
|
||||
t.Fatalf("args: %+v", cmds[0].Args)
|
||||
}
|
||||
}
|
||||
135
server/internal/ai/mission_prompt.go
Normal file
135
server/internal/ai/mission_prompt.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Default spread onion tiers (14) for prompt context.
|
||||
var defaultSpreadTiers = []string{
|
||||
"vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl",
|
||||
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh",
|
||||
"smb", "winrm", "linux", "gpo",
|
||||
}
|
||||
|
||||
// SystemPrompt returns the fleet AI system instructions.
|
||||
func SystemPrompt() string {
|
||||
return strings.TrimSpace(`You are the AetherForge fleet controller for the operator's own machines.
|
||||
Respond with short answers only. Prefer JSON: {"commands":[{"type":"noop","args":{}}]}.
|
||||
Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, stage_fetch, set_agent_version, noop.
|
||||
agent_command args: action (required), command, path, data.
|
||||
bulk_command args: agent_ids (array), action, command.
|
||||
reorder_tiers args: tier_order (array of strings), skip_tiers (optional array).
|
||||
stage_fetch args: data (JSON manifest string).
|
||||
set_agent_version args: module or build_id.
|
||||
You have complete control in AI mode. Never target third-party systems.`)
|
||||
}
|
||||
|
||||
// BuildUserPrompt renders the per-agent snapshot for one decision cycle.
|
||||
func BuildUserPrompt(s AgentSnapshot) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Agent: name=%q id=%s", s.Name, s.AgentID)
|
||||
if s.Worker != "" {
|
||||
fmt.Fprintf(&b, " worker=%s", s.Worker)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
fmt.Fprintf(&b, "Platform: GOOS=%s version=%s build=%s\n", firstNonEmpty(s.GOOS, s.Platform), s.Version, s.BuildID)
|
||||
if len(s.Capabilities) > 0 {
|
||||
flags := make([]string, 0, len(s.Capabilities))
|
||||
for k, v := range s.Capabilities {
|
||||
if v {
|
||||
flags = append(flags, k)
|
||||
}
|
||||
}
|
||||
if len(flags) > 0 {
|
||||
fmt.Fprintf(&b, "Forge capabilities: %s\n", strings.Join(flags, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "LOTL tier: %s\n", emptyDash(s.LOTLTier))
|
||||
fmt.Fprintf(&b, "Mining hashrate: %.2f H/s\n", s.MiningHashrate)
|
||||
if s.ActiveMethod != "" {
|
||||
fmt.Fprintf(&b, "Active method: %s\n", s.ActiveMethod)
|
||||
}
|
||||
if s.ChainExhausted {
|
||||
b.WriteString("Mining chain exhausted: true\n")
|
||||
}
|
||||
if len(s.ChainOrder) > 0 {
|
||||
fmt.Fprintf(&b, "Chain order: %s\n", strings.Join(s.ChainOrder, " → "))
|
||||
}
|
||||
|
||||
b.WriteString("LOTL attempts (all tiers):\n")
|
||||
attemptByTier := map[string]TierAttempt{}
|
||||
for _, a := range s.LOTLAttempts {
|
||||
attemptByTier[a.Tier] = a
|
||||
}
|
||||
for _, tier := range defaultSpreadTiers {
|
||||
if a, ok := attemptByTier[tier]; ok {
|
||||
status := "fail"
|
||||
if a.OK {
|
||||
status = "ok"
|
||||
}
|
||||
if a.Error != "" {
|
||||
fmt.Fprintf(&b, " - %s: %s (%s)\n", tier, status, a.Error)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " - %s: %s\n", tier, status)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(&b, " - %s: pending\n", tier)
|
||||
}
|
||||
}
|
||||
for _, a := range s.LOTLAttempts {
|
||||
if _, listed := attemptByTier[a.Tier]; listed {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range defaultSpreadTiers {
|
||||
if t == a.Tier {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
status := "fail"
|
||||
if a.OK {
|
||||
status = "ok"
|
||||
}
|
||||
fmt.Fprintf(&b, " - %s: %s\n", a.Tier, status)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "Join lane: %s\n", emptyDash(s.JoinLane))
|
||||
fmt.Fprintf(&b, "Spread state: %s\n", emptyDash(s.SpreadState))
|
||||
if s.VulnRisk != nil {
|
||||
fmt.Fprintf(&b, "Vuln risk score: %d\n", *s.VulnRisk)
|
||||
} else {
|
||||
b.WriteString("Vuln risk score: n/a\n")
|
||||
}
|
||||
if s.AdaptiveSummary != "" {
|
||||
fmt.Fprintf(&b, "Adaptive strategy summary: %s\n", s.AdaptiveSummary)
|
||||
} else if s.Adaptive != nil {
|
||||
if raw, err := json.Marshal(s.Adaptive); err == nil {
|
||||
fmt.Fprintf(&b, "Adaptive strategy: %s\n", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nIf all 14 tiers failed and hashrate=0, you MAY force restart mining chain (restart_mining).\n")
|
||||
b.WriteString("Return JSON commands array for this agent only.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func emptyDash(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "—"
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
188
server/internal/ai/scheduler.go
Normal file
188
server/internal/ai/scheduler.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SnapshotProvider supplies live agent telemetry for decision cycles.
|
||||
type SnapshotProvider interface {
|
||||
ConnectedAgentIDs() []string
|
||||
AgentSnapshot(agentID string) (AgentSnapshot, bool)
|
||||
}
|
||||
|
||||
// CommandExecutor runs parsed fleet commands.
|
||||
type CommandExecutor interface {
|
||||
Execute(agentID string, cmd Command) (summary string, err error)
|
||||
}
|
||||
|
||||
// ConfigProvider reads current Fleet AI Control settings.
|
||||
type ConfigProvider interface {
|
||||
AIConfig() Config
|
||||
}
|
||||
|
||||
// DecisionStore persists decision audit rows.
|
||||
type DecisionStore interface {
|
||||
InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error
|
||||
}
|
||||
|
||||
// Scheduler runs periodic LLM decisions for online agents.
|
||||
type Scheduler struct {
|
||||
cfg ConfigProvider
|
||||
snap SnapshotProvider
|
||||
exec CommandExecutor
|
||||
store DecisionStore
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
lastRunMu sync.Mutex
|
||||
lastRun map[string]time.Time
|
||||
}
|
||||
|
||||
func NewScheduler(cfg ConfigProvider, snap SnapshotProvider, exec CommandExecutor, store DecisionStore) *Scheduler {
|
||||
return &Scheduler{
|
||||
cfg: cfg,
|
||||
snap: snap,
|
||||
exec: exec,
|
||||
store: store,
|
||||
stop: make(chan struct{}),
|
||||
lastRun: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
s.wg.Add(1)
|
||||
go s.loop()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
close(s.stop)
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Scheduler) loop() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tick runs one scheduler pass (exported for tests).
|
||||
func (s *Scheduler) Tick() {
|
||||
s.tick()
|
||||
}
|
||||
|
||||
func (s *Scheduler) tick() {
|
||||
if s.cfg == nil || s.snap == nil {
|
||||
return
|
||||
}
|
||||
cfg := s.cfg.AIConfig()
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
interval := time.Duration(cfg.IntervalSec) * time.Second
|
||||
if interval < time.Second {
|
||||
interval = 60 * time.Second
|
||||
}
|
||||
ids := s.snap.ConnectedAgentIDs()
|
||||
for i, agentID := range ids {
|
||||
if !s.shouldRun(agentID, interval, i) {
|
||||
continue
|
||||
}
|
||||
s.runAgent(context.Background(), agentID, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) shouldRun(agentID string, interval time.Duration, staggerIndex int) bool {
|
||||
s.lastRunMu.Lock()
|
||||
defer s.lastRunMu.Unlock()
|
||||
last, ok := s.lastRun[agentID]
|
||||
if !ok {
|
||||
offset := time.Duration(staggerIndex%max(1, int(interval/time.Second))) * time.Second
|
||||
if offset > 0 {
|
||||
s.lastRun[agentID] = time.Now().Add(-interval + offset)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return time.Since(last) >= interval
|
||||
}
|
||||
|
||||
func (s *Scheduler) markRun(agentID string) {
|
||||
s.lastRunMu.Lock()
|
||||
s.lastRun[agentID] = time.Now()
|
||||
s.lastRunMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
||||
snap, ok := s.snap.AgentSnapshot(agentID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userPrompt := BuildUserPrompt(snap)
|
||||
systemPrompt := SystemPrompt()
|
||||
promptHash := hashPrompt(userPrompt)
|
||||
|
||||
decide := Decide
|
||||
if DecideFunc != nil {
|
||||
decide = DecideFunc
|
||||
}
|
||||
response, err := decide(ctx, cfg.Endpoint, cfg.Model, systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
log.Printf("[fleet-ai] agent %s decide: %v", agentID, err)
|
||||
if s.store != nil {
|
||||
_ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error())
|
||||
}
|
||||
s.markRun(agentID)
|
||||
return
|
||||
}
|
||||
|
||||
cmds := ParseCommands(response)
|
||||
results := make([]string, 0, len(cmds))
|
||||
for _, cmd := range cmds {
|
||||
if cmd.Type == CmdNoop {
|
||||
results = append(results, "ok")
|
||||
continue
|
||||
}
|
||||
if s.exec == nil {
|
||||
results = append(results, "no executor")
|
||||
continue
|
||||
}
|
||||
sum, execErr := s.exec.Execute(agentID, cmd)
|
||||
if execErr != nil {
|
||||
results = append(results, "err:"+execErr.Error())
|
||||
} else {
|
||||
results = append(results, sum)
|
||||
}
|
||||
}
|
||||
executed := FormatExecuted(cmds, results)
|
||||
if s.store != nil {
|
||||
_ = s.store.InsertAIDecision(agentID, promptHash, response, executed)
|
||||
}
|
||||
s.markRun(agentID)
|
||||
}
|
||||
|
||||
func hashPrompt(prompt string) string {
|
||||
h := sha256.Sum256([]byte(prompt))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
// DecideFunc allows tests to override LLM calls.
|
||||
var DecideFunc func(ctx context.Context, endpoint, model, systemPrompt, userPrompt string) (string, error)
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
92
server/internal/ai/scheduler_test.go
Normal file
92
server/internal/ai/scheduler_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mockSnap struct {
|
||||
ids []string
|
||||
snap AgentSnapshot
|
||||
}
|
||||
|
||||
func (m *mockSnap) ConnectedAgentIDs() []string { return m.ids }
|
||||
func (m *mockSnap) AgentSnapshot(string) (AgentSnapshot, bool) {
|
||||
return m.snap, true
|
||||
}
|
||||
|
||||
type mockExec struct {
|
||||
mu sync.Mutex
|
||||
calls []Command
|
||||
}
|
||||
|
||||
func (m *mockExec) Execute(_ string, cmd Command) (string, error) {
|
||||
m.mu.Lock()
|
||||
m.calls = append(m.calls, cmd)
|
||||
m.mu.Unlock()
|
||||
return cmd.Type, nil
|
||||
}
|
||||
|
||||
type mockCfg struct{ cfg Config }
|
||||
|
||||
func (m *mockCfg) AIConfig() Config { return m.cfg }
|
||||
|
||||
type mockStore struct {
|
||||
mu sync.Mutex
|
||||
rows []string
|
||||
}
|
||||
|
||||
func (m *mockStore) InsertAIDecision(_, _, _, executed string) error {
|
||||
m.mu.Lock()
|
||||
m.rows = append(m.rows, executed)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSchedulerExecutesRestartCommand(t *testing.T) {
|
||||
old := DecideFunc
|
||||
defer func() { DecideFunc = old }()
|
||||
DecideFunc = func(_ context.Context, _, _, _, _ string) (string, error) {
|
||||
return `{"commands":[{"type":"restart_mining","args":{}}]}`, nil
|
||||
}
|
||||
|
||||
exec := &mockExec{}
|
||||
store := &mockStore{}
|
||||
sched := NewScheduler(
|
||||
&mockCfg{cfg: Config{Enabled: true, Endpoint: "http://test/v1", IntervalSec: 1}},
|
||||
&mockSnap{ids: []string{"agent-1"}, snap: AgentSnapshot{AgentID: "agent-1", Name: "host"}},
|
||||
exec,
|
||||
store,
|
||||
)
|
||||
sched.lastRun["agent-1"] = time.Now().Add(-2 * time.Minute)
|
||||
sched.Tick()
|
||||
|
||||
exec.mu.Lock()
|
||||
n := len(exec.calls)
|
||||
call := exec.calls
|
||||
exec.mu.Unlock()
|
||||
if n != 1 || call[0].Type != CmdRestartMining {
|
||||
t.Fatalf("calls: %+v", call)
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if len(store.rows) != 1 || store.rows[0] != "restart_mining:restart_mining" {
|
||||
t.Fatalf("store: %v", store.rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerNoOpWhenDisabled(t *testing.T) {
|
||||
exec := &mockExec{}
|
||||
sched := NewScheduler(
|
||||
&mockCfg{cfg: Config{Enabled: false}},
|
||||
&mockSnap{ids: []string{"agent-1"}},
|
||||
exec,
|
||||
nil,
|
||||
)
|
||||
sched.Tick()
|
||||
if len(exec.calls) != 0 {
|
||||
t.Fatalf("expected no calls")
|
||||
}
|
||||
}
|
||||
63
server/internal/ai/types.go
Normal file
63
server/internal/ai/types.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package ai
|
||||
|
||||
import "crypto-miner-server/internal/strategy"
|
||||
|
||||
// TierAttempt mirrors agent LOTL tier attempt telemetry.
|
||||
type TierAttempt struct {
|
||||
Tier string `json:"tier"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
// AgentSnapshot is the per-cycle fleet state fed to the LLM.
|
||||
type AgentSnapshot struct {
|
||||
AgentID string
|
||||
Name string
|
||||
Worker string
|
||||
Platform string
|
||||
Version string
|
||||
BuildID string
|
||||
GOOS string
|
||||
|
||||
Capabilities map[string]bool
|
||||
|
||||
LOTLTier string
|
||||
LOTLAttempts []TierAttempt
|
||||
MiningHashrate float64
|
||||
ChainExhausted bool
|
||||
ChainOrder []string
|
||||
ActiveMethod string
|
||||
|
||||
JoinLane string
|
||||
SpreadState string
|
||||
VulnRisk *int
|
||||
|
||||
AdaptiveSummary string
|
||||
Adaptive *strategy.AdaptiveStrategy
|
||||
}
|
||||
|
||||
// Command is one fleet action parsed from LLM output.
|
||||
type Command struct {
|
||||
Type string `json:"type"`
|
||||
Args map[string]interface{} `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
// Config holds runtime Fleet AI Control settings.
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Endpoint string
|
||||
Model string
|
||||
NoContext bool
|
||||
IntervalSec int
|
||||
}
|
||||
|
||||
// DecisionRecord is persisted for the UI timeline.
|
||||
type DecisionRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
PromptHash string `json:"prompt_hash"`
|
||||
Response string `json:"response"`
|
||||
CommandsExecuted string `json:"commands_executed"`
|
||||
Timestamp string `json:"ts"`
|
||||
}
|
||||
344
server/internal/api/fleet_ai_bridge.go
Normal file
344
server/internal/api/fleet_ai_bridge.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
// FleetAIConfigView is the Calibrate subset for Fleet AI Control.
|
||||
type FleetAIConfigView struct {
|
||||
AIControlEnabled bool `json:"ai_control_enabled"`
|
||||
AIEndpoint string `json:"ai_endpoint"`
|
||||
AIModel string `json:"ai_model"`
|
||||
AINoContext bool `json:"ai_no_context"`
|
||||
AIDecisionIntervalSec int `json:"ai_decision_interval_sec"`
|
||||
}
|
||||
|
||||
// FleetAIConfigSource reads/writes Fleet AI settings from server config.
|
||||
type FleetAIConfigSource interface {
|
||||
GetFleetAIConfig() FleetAIConfigView
|
||||
UpdateFleetAIConfig(FleetAIConfigView) error
|
||||
}
|
||||
|
||||
// FleetAISnapshot builds agent snapshots from DB + WS hub state.
|
||||
func (h *WSHub) FleetAISnapshot(agentID string) (fleetai.AgentSnapshot, bool) {
|
||||
if h == nil || h.db == nil || agentID == "" {
|
||||
return fleetai.AgentSnapshot{}, false
|
||||
}
|
||||
agent, err := h.db.GetAgent(agentID)
|
||||
if err != nil || agent == nil {
|
||||
return fleetai.AgentSnapshot{}, false
|
||||
}
|
||||
|
||||
snap := fleetai.AgentSnapshot{
|
||||
AgentID: agentID,
|
||||
Name: agent.Name,
|
||||
Worker: agent.WorkerName,
|
||||
Platform: agent.Platform,
|
||||
Version: agent.Version,
|
||||
BuildID: agent.BuildID,
|
||||
GOOS: agent.Platform,
|
||||
MiningHashrate: agent.MiningHashrate,
|
||||
LOTLTier: agent.LOTLTier,
|
||||
JoinLane: agent.JoinLane,
|
||||
ChainExhausted: agent.ChainExhausted,
|
||||
ChainOrder: append([]string(nil), agent.ChainOrder...),
|
||||
ActiveMethod: agent.ActiveMethod,
|
||||
VulnRisk: agent.VulnRiskScore,
|
||||
}
|
||||
for _, a := range agent.LOTLAttempts {
|
||||
snap.LOTLAttempts = append(snap.LOTLAttempts, fleetai.TierAttempt{
|
||||
Tier: a.Tier, OK: a.OK, Error: a.Error, DurationMs: a.DurationMs,
|
||||
})
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if caps, ok := h.agentCapabilities[agentID]; ok {
|
||||
snap.Capabilities = capabilityFlags(caps)
|
||||
}
|
||||
if tel, ok := h.agentLiveTelemetry[agentID]; ok {
|
||||
mergeTelemetryIntoSnapshot(&snap, tel)
|
||||
}
|
||||
engine := h.adaptiveEngine
|
||||
aiMode := h.serverPolicy.AIControlEnabled
|
||||
h.mu.RUnlock()
|
||||
|
||||
snap.SpreadState = describeSpreadState(agent, snap.Capabilities)
|
||||
if engine != nil && !aiMode {
|
||||
fp := strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain)
|
||||
adaptive := engine.StrategyForAgent(agentID, fp)
|
||||
snap.Adaptive = &adaptive
|
||||
if len(adaptive.Reasoning) > 0 {
|
||||
snap.AdaptiveSummary = adaptive.Reasoning[0].Action
|
||||
}
|
||||
}
|
||||
return snap, true
|
||||
}
|
||||
|
||||
func capabilityFlags(caps models.AgentCapabilities) map[string]bool {
|
||||
return map[string]bool{
|
||||
"hole_punch": caps.HolePunch,
|
||||
"remote_aggressive": caps.RemoteAggressive,
|
||||
"auto_spread": caps.AutoSpread,
|
||||
"mesh_p2p": caps.MeshP2P,
|
||||
"process_hollowing": caps.ProcessHollowing,
|
||||
"ai_enabled": caps.AIEnabled,
|
||||
"usb_spread": caps.USBSpread,
|
||||
}
|
||||
}
|
||||
|
||||
func describeSpreadState(agent *models.Agent, caps map[string]bool) string {
|
||||
parts := []string{}
|
||||
if agent.USBSpread || (caps != nil && caps["usb_spread"]) {
|
||||
parts = append(parts, "usb")
|
||||
}
|
||||
if caps != nil && caps["auto_spread"] {
|
||||
parts = append(parts, "auto_spread")
|
||||
}
|
||||
if agent.JoinLane != "" {
|
||||
parts = append(parts, "lane:"+agent.JoinLane)
|
||||
}
|
||||
if agent.Campaign != "" {
|
||||
parts = append(parts, "campaign:"+agent.Campaign)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "idle"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func mergeTelemetryIntoSnapshot(snap *fleetai.AgentSnapshot, tel map[string]interface{}) {
|
||||
if v, ok := tel["mining_hashrate"].(float64); ok && v > 0 {
|
||||
snap.MiningHashrate = v
|
||||
}
|
||||
if v, ok := tel["lotl_tier"].(string); ok && v != "" {
|
||||
snap.LOTLTier = v
|
||||
}
|
||||
if v, ok := tel["join_lane"].(string); ok && v != "" {
|
||||
snap.JoinLane = v
|
||||
}
|
||||
if v, ok := tel["chain_exhausted"].(bool); ok {
|
||||
snap.ChainExhausted = v
|
||||
}
|
||||
if v, ok := tel["active_method"].(string); ok && v != "" {
|
||||
snap.ActiveMethod = v
|
||||
}
|
||||
if raw, ok := tel["lotl_attempts"]; ok {
|
||||
if b, err := json.Marshal(raw); err == nil {
|
||||
var attempts []fleetai.TierAttempt
|
||||
if json.Unmarshal(b, &attempts) == nil && len(attempts) > 0 {
|
||||
snap.LOTLAttempts = attempts
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := tel["chain_order"]; ok {
|
||||
if b, err := json.Marshal(raw); err == nil {
|
||||
var order []string
|
||||
if json.Unmarshal(b, &order) == nil {
|
||||
snap.ChainOrder = order
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := tel["vuln_risk_score"].(float64); ok {
|
||||
n := int(v)
|
||||
snap.VulnRisk = &n
|
||||
}
|
||||
}
|
||||
|
||||
// FleetAIExecutor dispatches parsed LLM commands via existing WS command paths.
|
||||
type FleetAIExecutor struct {
|
||||
Hub *WSHub
|
||||
}
|
||||
|
||||
func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string, error) {
|
||||
if e == nil || e.Hub == nil {
|
||||
return "", fmt.Errorf("hub unavailable")
|
||||
}
|
||||
args := cmd.Args
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
switch cmd.Type {
|
||||
case fleetai.CmdNoop:
|
||||
return "noop", nil
|
||||
case fleetai.CmdRestartMining:
|
||||
if err := e.Hub.SendAgentCommand(agentID, "restart", nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "restart", nil
|
||||
case fleetai.CmdDiscoverAndJoin:
|
||||
if err := e.Hub.SendAgentCommand(agentID, "discover_and_join", args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "discover_and_join", nil
|
||||
case fleetai.CmdSpreadNow:
|
||||
if err := e.Hub.SendAgentCommand(agentID, "spread_now", args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "spread_now", nil
|
||||
case fleetai.CmdStageFetch:
|
||||
if err := e.Hub.SendAgentCommand(agentID, "stage_fetch", args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "stage_fetch", nil
|
||||
case fleetai.CmdSetAgentVersion:
|
||||
module, _ := args["module"].(string)
|
||||
if module == "" {
|
||||
module, _ = args["build_id"].(string)
|
||||
}
|
||||
if module == "" {
|
||||
return "", fmt.Errorf("set_agent_version requires module or build_id")
|
||||
}
|
||||
if err := e.Hub.SendAgentCommand(agentID, "fetch_module", map[string]interface{}{"module": module}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "fetch_module:" + module, nil
|
||||
case fleetai.CmdReorderTiers:
|
||||
return e.pushReorderTiers(agentID, args)
|
||||
case fleetai.CmdBulkCommand:
|
||||
return e.runBulkCommand(args)
|
||||
case fleetai.CmdAgentCommand:
|
||||
action, _ := args["action"].(string)
|
||||
if action == "" {
|
||||
return "", fmt.Errorf("agent_command requires action")
|
||||
}
|
||||
sendArgs := map[string]interface{}{}
|
||||
for _, k := range []string{"command", "path", "data", "tail_lines"} {
|
||||
if v, ok := args[k]; ok {
|
||||
sendArgs[k] = v
|
||||
}
|
||||
}
|
||||
if err := e.Hub.SendAgentCommand(agentID, action, sendArgs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return action, nil
|
||||
default:
|
||||
if err := e.Hub.SendAgentCommand(agentID, cmd.Type, args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cmd.Type, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *FleetAIExecutor) pushReorderTiers(agentID string, args map[string]interface{}) (string, error) {
|
||||
payload := map[string]interface{}{}
|
||||
if raw, ok := args["tier_order"]; ok {
|
||||
payload["tier_order"] = raw
|
||||
}
|
||||
if raw, ok := args["skip_tiers"]; ok {
|
||||
payload["skip_tiers"] = raw
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return "", fmt.Errorf("reorder_tiers requires tier_order")
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
if err := e.Hub.SendToAgent(agentID, Message{Type: "adaptive_strategy_update", Payload: body}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "reorder_tiers", nil
|
||||
}
|
||||
|
||||
func (e *FleetAIExecutor) runBulkCommand(args map[string]interface{}) (string, error) {
|
||||
action, _ := args["action"].(string)
|
||||
if action == "" {
|
||||
return "", fmt.Errorf("bulk_command requires action")
|
||||
}
|
||||
ids := e.Hub.ResolveAgentTargets(parseAgentIDs(args["agent_ids"]))
|
||||
if len(ids) == 0 {
|
||||
return "", fmt.Errorf("bulk_command: no agent_ids")
|
||||
}
|
||||
sendArgs := map[string]interface{}{}
|
||||
if v, ok := args["command"]; ok {
|
||||
sendArgs["command"] = v
|
||||
}
|
||||
sent := 0
|
||||
for _, id := range ids {
|
||||
if err := e.Hub.SendAgentCommand(id, action, sendArgs); err == nil {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("bulk:%d/%d", sent, len(ids)), nil
|
||||
}
|
||||
|
||||
func parseAgentIDs(raw interface{}) []string {
|
||||
switch v := raw.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return v
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(v, ",")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WSHubSnapshotAdapter implements fleetai.SnapshotProvider.
|
||||
type WSHubSnapshotAdapter struct{ Hub *WSHub }
|
||||
|
||||
func (a *WSHubSnapshotAdapter) ConnectedAgentIDs() []string {
|
||||
if a == nil || a.Hub == nil {
|
||||
return nil
|
||||
}
|
||||
return a.Hub.ConnectedAgentIDs()
|
||||
}
|
||||
|
||||
func (a *WSHubSnapshotAdapter) AgentSnapshot(agentID string) (fleetai.AgentSnapshot, bool) {
|
||||
if a == nil || a.Hub == nil {
|
||||
return fleetai.AgentSnapshot{}, false
|
||||
}
|
||||
return a.Hub.FleetAISnapshot(agentID)
|
||||
}
|
||||
|
||||
// ConfigAIAdapter wraps FleetAIConfigSource for the scheduler.
|
||||
type ConfigAIAdapter struct{ Src FleetAIConfigSource }
|
||||
|
||||
func (a *ConfigAIAdapter) AIConfig() fleetai.Config {
|
||||
if a == nil || a.Src == nil {
|
||||
return fleetai.Config{}
|
||||
}
|
||||
v := a.Src.GetFleetAIConfig()
|
||||
interval := v.AIDecisionIntervalSec
|
||||
if interval <= 0 {
|
||||
interval = 60
|
||||
}
|
||||
endpoint := strings.TrimSpace(v.AIEndpoint)
|
||||
if endpoint == "" {
|
||||
endpoint = "http://127.0.0.1:11434/v1"
|
||||
}
|
||||
return fleetai.Config{
|
||||
Enabled: v.AIControlEnabled,
|
||||
Endpoint: endpoint,
|
||||
Model: strings.TrimSpace(v.AIModel),
|
||||
NoContext: v.AINoContext,
|
||||
IntervalSec: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// DatabaseAIDecisionStore wraps db for InsertAIDecision.
|
||||
type DatabaseAIDecisionStore struct {
|
||||
DB interface {
|
||||
InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DatabaseAIDecisionStore) InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error {
|
||||
if s == nil || s.DB == nil {
|
||||
return nil
|
||||
}
|
||||
return s.DB.InsertAIDecision(agentID, promptHash, response, commandsExecuted)
|
||||
}
|
||||
90
server/internal/api/fleet_ai_handler.go
Normal file
90
server/internal/api/fleet_ai_handler.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
// FleetAIHandler serves Fleet AI Control API routes.
|
||||
type FleetAIHandler struct {
|
||||
config FleetAIConfigSource
|
||||
db interface {
|
||||
ListAIDecisions(agentID string, limit int) ([]db.AIDecisionRecord, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewFleetAIHandler(cfg FleetAIConfigSource, database interface {
|
||||
ListAIDecisions(agentID string, limit int) ([]db.AIDecisionRecord, error)
|
||||
}) *FleetAIHandler {
|
||||
return &FleetAIHandler{config: cfg, db: database}
|
||||
}
|
||||
|
||||
func (h *FleetAIHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config == nil {
|
||||
http.Error(w, "config unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, h.config.GetFleetAIConfig())
|
||||
}
|
||||
|
||||
func (h *FleetAIHandler) PutConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config == nil {
|
||||
http.Error(w, "config unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var body FleetAIConfigView
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.AIDecisionIntervalSec < 0 {
|
||||
http.Error(w, "ai_decision_interval_sec must be ≥ 0", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.config.UpdateFleetAIConfig(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, h.config.GetFleetAIConfig())
|
||||
}
|
||||
|
||||
func (h *FleetAIHandler) GetModels(w http.ResponseWriter, r *http.Request) {
|
||||
endpoint := strings.TrimSpace(r.URL.Query().Get("endpoint"))
|
||||
if endpoint == "" && h.config != nil {
|
||||
endpoint = h.config.GetFleetAIConfig().AIEndpoint
|
||||
}
|
||||
models, err := fleetai.ListModels(r.Context(), endpoint)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"models": models, "endpoint": endpoint})
|
||||
}
|
||||
|
||||
func (h *FleetAIHandler) GetDecisions(w http.ResponseWriter, r *http.Request) {
|
||||
if h.db == nil {
|
||||
writeJSON(w, []db.AIDecisionRecord{})
|
||||
return
|
||||
}
|
||||
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
|
||||
limit := 50
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := h.db.ListAIDecisions(agentID, limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []db.AIDecisionRecord{}
|
||||
}
|
||||
writeJSON(w, rows)
|
||||
}
|
||||
@@ -39,6 +39,18 @@ func (m *mockConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConfigProvider) GetFleetAIConfig() FleetAIConfigView {
|
||||
return FleetAIConfigView{
|
||||
AIEndpoint: "http://127.0.0.1:11434/v1",
|
||||
AINoContext: true,
|
||||
AIDecisionIntervalSec: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockConfigProvider) UpdateFleetAIConfig(v FleetAIConfigView) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const testAuthUser = "testuser"
|
||||
const testAuthPass = "testpass"
|
||||
const testFleetSecret = "test-fleet-secret-integration"
|
||||
@@ -80,7 +92,8 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
dropperHandler := NewDropperHandler(database, dataDir, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
|
||||
fleetAIHandler := NewFleetAIHandler(cfg, database)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, fleetAIHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
|
||||
}
|
||||
|
||||
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
@@ -156,7 +169,8 @@ func newFusionTestRouter(t *testing.T, projectRoot string) (http.Handler, *WSHub
|
||||
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
|
||||
|
||||
dropperHandler := NewDropperHandler(database, dataDir, nil)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
|
||||
fleetAIHandler := NewFleetAIHandler(cfg, database)
|
||||
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, fleetAIHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
|
||||
}
|
||||
|
||||
func fusionMultipartBody(t *testing.T) (*bytes.Buffer, string) {
|
||||
|
||||
@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
|
||||
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
|
||||
|
||||
dlURL := "/api/v1/builds/" + buildID + "/download"
|
||||
|
||||
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
||||
blueprintHandler := NewBlueprintHandler(dataDir)
|
||||
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
|
||||
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -13,6 +13,8 @@ type ServerPolicy struct {
|
||||
ServiceDeployAllowlist map[string]ServiceDeployLane
|
||||
MiningTierPolicy MiningTierPolicy
|
||||
TripleOnionPolicy TripleOnionPolicy
|
||||
// AIControlEnabled replaces adaptive_strategy when true (Fleet AI Control).
|
||||
AIControlEnabled bool
|
||||
}
|
||||
|
||||
// TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth.
|
||||
|
||||
@@ -155,6 +155,7 @@ type WSHub struct {
|
||||
agentDNS map[string][]string
|
||||
// Latest service_discover payloads keyed by agent ID (Crucible service graph).
|
||||
agentServiceDiscover map[string]cachedServiceDiscover
|
||||
agentLiveTelemetry map[string]map[string]interface{}
|
||||
serverPolicy ServerPolicy
|
||||
adaptiveEngine *strategy.AdaptiveEngine
|
||||
pingIntervalSec int
|
||||
@@ -198,6 +199,7 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
agentLogs: make(map[string]string),
|
||||
agentDNS: make(map[string][]string),
|
||||
agentServiceDiscover: make(map[string]cachedServiceDiscover),
|
||||
agentLiveTelemetry: make(map[string]map[string]interface{}),
|
||||
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
|
||||
beaconLastSeen: make(map[string]time.Time),
|
||||
beaconCmdQueue: make(map[string][]BeaconCommand),
|
||||
@@ -280,7 +282,7 @@ func (h *WSHub) runAdaptiveStrategyLoop() {
|
||||
h.mu.RLock()
|
||||
engine := h.adaptiveEngine
|
||||
h.mu.RUnlock()
|
||||
if engine == nil || !engine.Enabled() {
|
||||
if engine == nil || !engine.Enabled() || h.serverPolicy.AIControlEnabled {
|
||||
continue
|
||||
}
|
||||
if _, err := engine.RecomputeAll(); err != nil {
|
||||
@@ -877,7 +879,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
resp["triple_onion_policy"] = top
|
||||
if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() {
|
||||
if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() && !policy.AIControlEnabled {
|
||||
domainJoined := false
|
||||
if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain {
|
||||
domainJoined = true
|
||||
@@ -1542,6 +1544,27 @@ func mergeStatsPayload(existing, incoming json.RawMessage) json.RawMessage {
|
||||
return mustMarshal(base)
|
||||
}
|
||||
|
||||
// queueStatsBroadcast accumulates per-agent stats and flushes one stats_batch
|
||||
// message per interval instead of N individual stats_update frames.
|
||||
func (h *WSHub) cacheAgentTelemetry(agentID string, payload map[string]interface{}) {
|
||||
if agentID == "" || len(payload) == 0 {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
cur, ok := h.agentLiveTelemetry[agentID]
|
||||
if !ok {
|
||||
cur = make(map[string]interface{})
|
||||
h.agentLiveTelemetry[agentID] = cur
|
||||
}
|
||||
for k, v := range payload {
|
||||
if k == "agent_id" {
|
||||
continue
|
||||
}
|
||||
cur[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// queueStatsBroadcast accumulates per-agent stats and flushes one stats_batch
|
||||
// message per interval instead of N individual stats_update frames.
|
||||
func (h *WSHub) queueStatsBroadcast(payload map[string]interface{}) {
|
||||
@@ -1559,6 +1582,7 @@ func (h *WSHub) queueStatsBroadcast(payload map[string]interface{}) {
|
||||
data = mergeStatsPayload(prev, data)
|
||||
}
|
||||
h.statsBatch[agentID] = data
|
||||
h.cacheAgentTelemetry(agentID, payload)
|
||||
if h.statsBatchTimer == nil {
|
||||
h.statsBatchTimer = time.AfterFunc(statsBatchInterval, h.flushStatsBatch)
|
||||
}
|
||||
@@ -1676,6 +1700,7 @@ func (h *WSHub) RemoveAgent(agentID string) {
|
||||
delete(h.agentConfigs, agentID)
|
||||
delete(h.agentLogs, agentID)
|
||||
delete(h.agentCapabilities, agentID)
|
||||
delete(h.agentLiveTelemetry, agentID)
|
||||
ac.Conn.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
@@ -1729,7 +1754,7 @@ func (h *WSHub) PushAdaptiveStrategyUpdates() int {
|
||||
engine := h.adaptiveEngine
|
||||
ids := h.ConnectedAgentIDs()
|
||||
h.mu.RUnlock()
|
||||
if engine == nil || !engine.Enabled() {
|
||||
if engine == nil || !engine.Enabled() || h.serverPolicy.AIControlEnabled {
|
||||
return 0
|
||||
}
|
||||
sent := 0
|
||||
@@ -1754,7 +1779,7 @@ func (h *WSHub) PushAdaptiveStrategyUpdates() int {
|
||||
}
|
||||
|
||||
func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]interface{}) {
|
||||
if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() {
|
||||
if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() || h.serverPolicy.AIControlEnabled {
|
||||
return
|
||||
}
|
||||
platform, _ := payload["platform"].(string)
|
||||
@@ -1785,7 +1810,7 @@ func (h *WSHub) ingestStrategyFromStats(
|
||||
miningHashrate float64,
|
||||
activeTier string,
|
||||
) {
|
||||
if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() {
|
||||
if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() || h.serverPolicy.AIControlEnabled {
|
||||
return
|
||||
}
|
||||
if platform == "" || ip == "" {
|
||||
|
||||
98
server/internal/db/ai_decisions.go
Normal file
98
server/internal/db/ai_decisions.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AIDecisionRecord is one persisted fleet AI decision cycle.
|
||||
type AIDecisionRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
PromptHash string `json:"prompt_hash"`
|
||||
Response string `json:"response"`
|
||||
CommandsExecuted string `json:"commands_executed"`
|
||||
Timestamp string `json:"ts"`
|
||||
}
|
||||
|
||||
func (d *Database) ensureAIDecisionsTable() error {
|
||||
_, err := d.Exec(`CREATE TABLE IF NOT EXISTS ai_decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
prompt_hash TEXT NOT NULL DEFAULT '',
|
||||
response TEXT NOT NULL DEFAULT '',
|
||||
commands_executed TEXT NOT NULL DEFAULT '',
|
||||
ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_ai_decisions_agent ON ai_decisions(agent_id)`)
|
||||
_, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_ai_decisions_ts ON ai_decisions(ts)`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertAIDecision logs one fleet AI decision cycle.
|
||||
func (d *Database) InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if err := d.ensureAIDecisionsTable(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO ai_decisions (agent_id, prompt_hash, response, commands_executed) VALUES (?, ?, ?, ?)`,
|
||||
agentID, promptHash, response, commandsExecuted,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAIDecisions returns the most recent decisions for an agent (or all agents when agentID empty).
|
||||
func (d *Database) ListAIDecisions(agentID string, limit int) ([]AIDecisionRecord, error) {
|
||||
if d == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := d.ensureAIDecisionsTable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID != "" {
|
||||
rows, err = d.Query(
|
||||
`SELECT id, agent_id, prompt_hash, response, commands_executed, ts
|
||||
FROM ai_decisions WHERE agent_id = ? ORDER BY id DESC LIMIT ?`,
|
||||
agentID, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = d.Query(
|
||||
`SELECT id, agent_id, prompt_hash, response, commands_executed, ts
|
||||
FROM ai_decisions ORDER BY id DESC LIMIT ?`,
|
||||
limit,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list ai decisions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AIDecisionRecord, 0, limit)
|
||||
for rows.Next() {
|
||||
var rec AIDecisionRecord
|
||||
var ts string
|
||||
if err := rows.Scan(&rec.ID, &rec.AgentID, &rec.PromptHash, &rec.Response, &rec.CommandsExecuted, &ts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Timestamp = ts
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"crypto-miner-server/internal/builder"
|
||||
"crypto-miner-server/internal/cloudflared"
|
||||
"crypto-miner-server/internal/db"
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/maintenance"
|
||||
"crypto-miner-server/internal/pool"
|
||||
"crypto-miner-server/internal/scheduler"
|
||||
@@ -268,6 +269,17 @@ func main() {
|
||||
defer fleetSched.Stop()
|
||||
wsHub.SetConnectTaskRunner(fleetSched)
|
||||
|
||||
fleetAISched := fleetai.NewScheduler(
|
||||
&api.ConfigAIAdapter{Src: configProvider},
|
||||
&api.WSHubSnapshotAdapter{Hub: wsHub},
|
||||
&api.FleetAIExecutor{Hub: wsHub},
|
||||
&api.DatabaseAIDecisionStore{DB: database},
|
||||
)
|
||||
fleetAISched.Start()
|
||||
defer fleetAISched.Stop()
|
||||
fleetAIHandler := api.NewFleetAIHandler(configProvider, database)
|
||||
log.Println("Fleet AI Control scheduler initialized")
|
||||
|
||||
// Initialize blueprint handler (config presets)
|
||||
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
|
||||
log.Println("Blueprint handler initialized")
|
||||
@@ -305,7 +317,7 @@ func main() {
|
||||
log.Printf("Web root: %s", webRoot)
|
||||
|
||||
// Initialize router
|
||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, spreadCredHandler, deployPlanHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
|
||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, fleetAIHandler, dropperHandler, spreadHandler, spreadCredHandler, deployPlanHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
|
||||
return configProvider.PublicURL()
|
||||
}, cfg.Port, func() bool {
|
||||
return cfg.ConnectorToken() != ""
|
||||
@@ -379,6 +391,7 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager
|
||||
ReconTiers: cfg.Server.TripleOnionPolicy.ReconTiers,
|
||||
DeployLanes: cfg.Server.TripleOnionPolicy.DeployLanes,
|
||||
},
|
||||
AIControlEnabled: cfg.Server.AIControlEnabled,
|
||||
})
|
||||
}
|
||||
if poolManager != nil {
|
||||
@@ -479,6 +492,46 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *serverConfigProvider) GetFleetAIConfig() api.FleetAIConfigView {
|
||||
if p == nil || p.config == nil {
|
||||
return api.FleetAIConfigView{}
|
||||
}
|
||||
s := p.config.Server
|
||||
interval := s.AIDecisionIntervalSec
|
||||
if interval <= 0 {
|
||||
interval = 60
|
||||
}
|
||||
return api.FleetAIConfigView{
|
||||
AIControlEnabled: s.AIControlEnabled,
|
||||
AIEndpoint: s.AIEndpoint,
|
||||
AIModel: s.AIModel,
|
||||
AINoContext: s.AINoContext,
|
||||
AIDecisionIntervalSec: interval,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *serverConfigProvider) UpdateFleetAIConfig(v api.FleetAIConfigView) error {
|
||||
if p == nil || p.config == nil {
|
||||
return fmt.Errorf("config unavailable")
|
||||
}
|
||||
if v.AIDecisionIntervalSec < 0 {
|
||||
return fmt.Errorf("ai_decision_interval_sec must be ≥ 0")
|
||||
}
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"ai_control_enabled": v.AIControlEnabled,
|
||||
"ai_endpoint": v.AIEndpoint,
|
||||
"ai_model": v.AIModel,
|
||||
"ai_no_context": v.AINoContext,
|
||||
"ai_decision_interval_sec": v.AIDecisionIntervalSec,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.UpdateConfigFromJSON(payload)
|
||||
}
|
||||
|
||||
// findAgentSourceDir locates the agent source code directory
|
||||
// It searches relative to the server binary location and the current working directory
|
||||
func findAgentSourceDir() string {
|
||||
|
||||
@@ -237,6 +237,7 @@ describe('api client', () => {
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse({ models: ['llama3.2'] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ xmr_per_day: 0.01, usd_per_day: 1, network_hashrate: 1 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true }))
|
||||
.mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' }))
|
||||
@@ -253,6 +254,9 @@ describe('api client', () => {
|
||||
await api.getAIActivity();
|
||||
expect(lastFetch().url).toBe('/api/v1/ai/activity');
|
||||
|
||||
await api.getAIModels('http://127.0.0.1:11434/v1');
|
||||
expect(lastFetch().url).toBe('/api/v1/ai/models?endpoint=http%3A%2F%2F127.0.0.1%3A11434%2Fv1');
|
||||
|
||||
await api.getEarningsEstimate(1234.5);
|
||||
expect(lastFetch().url).toBe('/api/v1/earnings/estimate?hashrate=1234.5');
|
||||
|
||||
|
||||
176
server/web/src/components/CalibrationAIControl.tsx
Normal file
176
server/web/src/components/CalibrationAIControl.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState } from 'react';
|
||||
import type { ServerSettings } from '../types';
|
||||
import { api } from '../api/client';
|
||||
import { HelpTip, FieldHint } from './HelpTip';
|
||||
import { ADAPTIVE_STRATEGY_HELP } from '../help/lotlOnionTiers';
|
||||
|
||||
export const DEFAULT_AI_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1';
|
||||
export const DEFAULT_AI_INTERVAL_SEC = 60;
|
||||
|
||||
function readEndpoint(server: ServerSettings): string {
|
||||
return server.ai_endpoint?.trim() || server.ai_local_endpoint?.trim() || DEFAULT_AI_LOCAL_ENDPOINT;
|
||||
}
|
||||
|
||||
function readIntervalSec(server: ServerSettings): number {
|
||||
return server.ai_decision_interval_sec ?? server.ai_interval_sec ?? DEFAULT_AI_INTERVAL_SEC;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
server: ServerSettings;
|
||||
onUpdate: (path: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export default function CalibrationAIControl({ server, onUpdate }: Props) {
|
||||
const aiControl = server.ai_control_enabled ?? false;
|
||||
const endpoint = readEndpoint(server);
|
||||
const model = server.ai_model?.trim() || '';
|
||||
const intervalSec = readIntervalSec(server);
|
||||
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [modelsMsg, setModelsMsg] = useState('');
|
||||
|
||||
const handleRefreshModels = async () => {
|
||||
setRefreshing(true);
|
||||
setModelsMsg('');
|
||||
try {
|
||||
const res = await api.getAIModels(endpoint);
|
||||
setModels(res.models ?? []);
|
||||
if (!res.models?.length) {
|
||||
setModelsMsg(res.error || 'No models returned — is Ollama running?');
|
||||
} else {
|
||||
setModelsMsg(`${res.models.length} model(s) loaded`);
|
||||
if (!model && res.models[0]) {
|
||||
onUpdate('server.ai_model', res.models[0]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setModels([]);
|
||||
setModelsMsg(e instanceof Error ? e.message : 'Failed to refresh models');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="calibration-ai-control">
|
||||
<div
|
||||
className="calibration-mode-toggle"
|
||||
role="group"
|
||||
aria-label="Calibration control mode"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`calibration-mode-btn ${!aiControl ? 'calibration-mode-btn--active' : ''}`}
|
||||
aria-pressed={!aiControl}
|
||||
onClick={() => onUpdate('server.ai_control_enabled', false)}
|
||||
>
|
||||
<span className="calibration-mode-label">Logic gates</span>
|
||||
<span className="calibration-mode-sub">Adaptive strategy & tier chains</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`calibration-mode-btn ${aiControl ? 'calibration-mode-btn--active' : ''}`}
|
||||
aria-pressed={aiControl}
|
||||
onClick={() => onUpdate('server.ai_control_enabled', true)}
|
||||
>
|
||||
<span className="calibration-mode-label">AI Control</span>
|
||||
<span className="calibration-mode-sub">Local LLM fleet decisions</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{aiControl ? (
|
||||
<div className="calibration-ai-panel">
|
||||
<p className="section-desc calibration-ai-blurb">
|
||||
Fleet AI issues <strong>stateless</strong> decisions every {intervalSec}s per agent — no memory
|
||||
between cycles. The control server calls your local LLM; agents execute tool calls on{' '}
|
||||
<em>your</em> machines only. Complete fleet control stays on your LAN.
|
||||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cal-ai-endpoint" className="label">
|
||||
Local API URL <HelpTip field="ai_local_endpoint" />
|
||||
</label>
|
||||
<input
|
||||
id="cal-ai-endpoint"
|
||||
type="url"
|
||||
className="input mono"
|
||||
value={endpoint}
|
||||
placeholder={DEFAULT_AI_LOCAL_ENDPOINT}
|
||||
onChange={(e) => onUpdate('server.ai_endpoint', e.target.value)}
|
||||
/>
|
||||
<FieldHint field="ai_local_endpoint" />
|
||||
</div>
|
||||
|
||||
<div className="form-row calibration-ai-model-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={refreshing}
|
||||
onClick={handleRefreshModels}
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh models'}
|
||||
</button>
|
||||
<div className="form-group" style={{ flex: 1, margin: 0 }}>
|
||||
<label htmlFor="cal-ai-model" className="label">
|
||||
Model <HelpTip field="calibration_ai_model" />
|
||||
</label>
|
||||
<select
|
||||
id="cal-ai-model"
|
||||
className="input"
|
||||
value={model}
|
||||
onChange={(e) => onUpdate('server.ai_model', e.target.value)}
|
||||
>
|
||||
<option value="">{models.length ? 'Select a model…' : 'Refresh models first'}</option>
|
||||
{model && !models.includes(model) && (
|
||||
<option value={model}>{model}</option>
|
||||
)}
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{modelsMsg && (
|
||||
<p className="form-hint calibration-ai-models-msg">{modelsMsg}</p>
|
||||
)}
|
||||
|
||||
<div className="calibration-ai-meta">
|
||||
<div className="calibration-ai-info-chip">
|
||||
<span className="font-tech">ai_no_context</span>
|
||||
<span className="calibration-ai-info-value">always on</span>
|
||||
<HelpTip field="ai_no_context" />
|
||||
</div>
|
||||
<div className="calibration-ai-info-chip">
|
||||
<span className="font-tech">Interval</span>
|
||||
<span className="calibration-ai-info-value">{intervalSec}s per agent</span>
|
||||
<HelpTip field="ai_interval_sec" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="calibration-logic-panel">
|
||||
<p className="section-desc">{ADAPTIVE_STRATEGY_HELP}</p>
|
||||
<FieldHint field="adaptive_strategy" />
|
||||
<FieldHint field="lotl_onion_tiers" />
|
||||
<div className="form-group checkbox-group" style={{ marginTop: '1rem' }}>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={server.adaptive_strategy_enabled !== false}
|
||||
onChange={(e) => onUpdate('server.adaptive_strategy_enabled', e.target.checked)}
|
||||
/>
|
||||
<span>Enable adaptive strategy engine <HelpTip field="adaptive_strategy" /></span>
|
||||
</label>
|
||||
</div>
|
||||
{server.lotl_onion_tiers?.length ? (
|
||||
<p className="form-hint" style={{ marginTop: '0.75rem' }}>
|
||||
Spread tier order: <code className="mono-sm">{server.lotl_onion_tiers.join(' → ')}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ const HELP_TIP_FIELDS = [
|
||||
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
|
||||
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
|
||||
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
|
||||
'calibration_ai_control', 'ai_local_endpoint', 'calibration_ai_model', 'ai_no_context', 'ai_interval_sec',
|
||||
'adaptive_strategy', 'lotl_onion_tiers',
|
||||
'forge_operation_mode', 'forge_path_forge',
|
||||
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
|
||||
'winrm_spread', 'dns_txt_spread', 'webrtc_mesh_spread', 'wsus_cache_peer_spread',
|
||||
|
||||
@@ -82,6 +82,13 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
ai_enabled: '/docs/#alerts-ai',
|
||||
ai_ollama_endpoint: '/docs/#alerts-ai',
|
||||
ai_model: '/docs/#alerts-ai',
|
||||
calibration_ai_control: '/docs/#alerts-ai',
|
||||
ai_local_endpoint: '/docs/#alerts-ai',
|
||||
calibration_ai_model: '/docs/#alerts-ai',
|
||||
ai_no_context: '/docs/#alerts-ai',
|
||||
ai_interval_sec: '/docs/#alerts-ai',
|
||||
adaptive_strategy: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
|
||||
lotl_onion_tiers: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
|
||||
|
||||
// Crucible / agent remote
|
||||
firewall_remote: '/docs/#crucible-ops',
|
||||
|
||||
@@ -96,6 +96,11 @@ describe('FIELD_HELP', () => {
|
||||
'log_pool_traffic',
|
||||
'adapt_to_hardware',
|
||||
'adaptive_strategy',
|
||||
'calibration_ai_control',
|
||||
'ai_local_endpoint',
|
||||
'calibration_ai_model',
|
||||
'ai_no_context',
|
||||
'ai_interval_sec',
|
||||
'self_healing',
|
||||
'firewall_exclusion',
|
||||
'firewall_remote',
|
||||
|
||||
@@ -29,9 +29,19 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
forge_lotl_onion:
|
||||
'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered vuln recon→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
|
||||
adaptive_strategy:
|
||||
'Fleet adaptive strategy learns LOTL mining tier order from your own machines (OS, Docker/WSL probes, subnet, hashrate outcomes). On connect the server pushes a personalized tier walk with strategy_reasoning bullets before the agent tries the default onion. Overrides order/skip hints only — not wallet or patch_first gates. Toggle with server.adaptive_strategy_enabled (default on).',
|
||||
'Fleet adaptive strategy learns LOTL mining tier order from your own machines (OS, Docker/WSL probes, subnet, hashrate outcomes). On connect the server pushes a personalized tier walk with strategy_reasoning bullets before the agent tries the default onion. Overrides order/skip hints only — not wallet or patch_first gates. Toggle with server.adaptive_strategy_enabled (default on). When server.ai_control_enabled is on, Fleet AI Control replaces adaptive strategy for tier decisions.',
|
||||
lotl_onion_tiers:
|
||||
'Ordered spread contingency chain for LOTL Onion forges with lotl_policy_from_server. Mining tier order is separate (mining_tier_policy / adaptive_strategy). Spread tiers apply on reconnect without re-forge; adaptive strategy can reorder mining tiers proactively from fleet stats.',
|
||||
calibration_ai_control:
|
||||
'Calibrate control mode: Logic gates use weighted adaptive strategy + server lotl_onion_tiers. AI Control routes fleet decisions through a local LLM on this control PC every 60s per agent — stateless, no memory, full tool authority on your machines only.',
|
||||
ai_local_endpoint:
|
||||
'Local OpenAI-compatible or Ollama API base URL on the control server machine (default http://127.0.0.1:11434/v1). The hub lists models and calls the LLM — workers never talk to Ollama directly.',
|
||||
calibration_ai_model:
|
||||
'LLM model name for fleet AI Control (Calibrate). Click Refresh models after Ollama is running, then pick from the dropdown. Distinct from per-forge ai_model baked into installers.',
|
||||
ai_no_context:
|
||||
'Stateless AI mode — each 60s cycle sends only the current agent snapshot. No chat history or cross-agent memory is retained (always on for fleet safety).',
|
||||
ai_interval_sec:
|
||||
'Seconds between AI decision cycles per connected agent when AI Control is enabled. Default 60 — matches agent heartbeat cadence.',
|
||||
forge_path_forge:
|
||||
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
|
||||
forge_recommended_defaults:
|
||||
|
||||
@@ -1300,6 +1300,102 @@ button.deliverable-card .form-hint {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Calibrate — Logic gates ↔ AI Control */
|
||||
.calibration-mode-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.65rem;
|
||||
margin: 1rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.calibration-mode-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem 1.15rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-brass);
|
||||
background: rgba(10, 10, 18, 0.65);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.calibration-mode-btn:hover {
|
||||
border-color: rgba(0, 232, 245, 0.35);
|
||||
background: rgba(0, 232, 245, 0.04);
|
||||
}
|
||||
|
||||
.calibration-mode-btn--active {
|
||||
border-color: rgba(232, 40, 168, 0.55);
|
||||
background: linear-gradient(145deg, rgba(232, 40, 168, 0.12) 0%, rgba(0, 232, 245, 0.06) 100%);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 24px rgba(232, 40, 168, 0.15);
|
||||
}
|
||||
|
||||
.calibration-mode-label {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.calibration-mode-btn--active .calibration-mode-label {
|
||||
color: var(--neon-magenta);
|
||||
}
|
||||
|
||||
.calibration-mode-sub {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.calibration-ai-model-row {
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.calibration-ai-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.calibration-ai-info-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-neon);
|
||||
background: rgba(0, 232, 245, 0.05);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.calibration-ai-info-chip .font-tech {
|
||||
color: var(--neon-amber);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.calibration-ai-info-value {
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.calibration-ai-models-msg {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.calibration-mode-toggle {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.forge-simple-banner {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
|
||||
@@ -173,6 +173,51 @@ describe('SettingsPage (Calibrate)', () => {
|
||||
expect(saved?.server?.public_builds_latest_n).toBe(3);
|
||||
});
|
||||
|
||||
it('renders Calibration Control mode toggle', async () => {
|
||||
renderSettings();
|
||||
expect(await screen.findByRole('group', { name: 'Calibration control mode' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Logic gates/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /AI Control/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Adaptive strategy & tier chains/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Enable adaptive strategy engine/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to AI Control and refreshes models', async () => {
|
||||
const modelsSpy = vi.spyOn(api, 'getAIModels').mockResolvedValue({
|
||||
models: ['llama3.2', 'mistral'],
|
||||
endpoint: 'http://127.0.0.1:11434/v1',
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
await screen.findByRole('button', { name: /AI Control/i });
|
||||
await user.click(screen.getByRole('button', { name: /AI Control/i }));
|
||||
expect(screen.getByPlaceholderText('http://127.0.0.1:11434/v1')).toBeInTheDocument();
|
||||
expect(screen.getByText(/ai_no_context/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/60s per agent/i).length).toBeGreaterThanOrEqual(1);
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh models' }));
|
||||
await waitFor(() => {
|
||||
expect(modelsSpy).toHaveBeenCalledWith('http://127.0.0.1:11434/v1');
|
||||
});
|
||||
expect(await screen.findByText('2 model(s) loaded')).toBeInTheDocument();
|
||||
const modelSelect = screen.getByRole('combobox', { name: /Model/i }) as HTMLSelectElement;
|
||||
expect(modelSelect.value).toBe('llama3.2');
|
||||
});
|
||||
|
||||
it('saves AI Control settings via updateConfig', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
await screen.findByRole('button', { name: /AI Control/i });
|
||||
await user.click(screen.getByRole('button', { name: /AI Control/i }));
|
||||
await user.click(screen.getByRole('button', { name: /save calibration/i }));
|
||||
await waitFor(() => {
|
||||
expect(api.updateConfig).toHaveBeenCalled();
|
||||
});
|
||||
const saved = vi.mocked(api.updateConfig).mock.calls.at(-1)?.[0];
|
||||
expect(saved?.server?.ai_control_enabled).toBe(true);
|
||||
expect(saved?.server?.ai_endpoint).toBe('http://127.0.0.1:11434/v1');
|
||||
expect(saved?.server?.ai_no_context).toBe(true);
|
||||
});
|
||||
|
||||
it('describes first-run admin credentials in Access Control help', async () => {
|
||||
renderSettings();
|
||||
expect(
|
||||
|
||||
@@ -34,6 +34,10 @@ import {
|
||||
buildDefenderExclusionScript,
|
||||
defaultWindowsInstallPreview,
|
||||
} from '../help/defenderExclusion';
|
||||
import CalibrationAIControl, {
|
||||
DEFAULT_AI_INTERVAL_SEC,
|
||||
DEFAULT_AI_LOCAL_ENDPOINT,
|
||||
} from '../components/CalibrationAIControl';
|
||||
|
||||
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
|
||||
export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
|
||||
@@ -182,7 +186,25 @@ export default function SettingsPage() {
|
||||
setSaving(true);
|
||||
setSaveMessage('');
|
||||
try {
|
||||
const updated = await api.updateConfig(config);
|
||||
let payload = config;
|
||||
if (config.server?.ai_control_enabled) {
|
||||
payload = {
|
||||
...config,
|
||||
server: {
|
||||
...config.server,
|
||||
ai_endpoint:
|
||||
config.server.ai_endpoint?.trim()
|
||||
|| config.server.ai_local_endpoint?.trim()
|
||||
|| DEFAULT_AI_LOCAL_ENDPOINT,
|
||||
ai_no_context: true,
|
||||
ai_decision_interval_sec:
|
||||
config.server.ai_decision_interval_sec
|
||||
?? config.server.ai_interval_sec
|
||||
?? DEFAULT_AI_INTERVAL_SEC,
|
||||
},
|
||||
};
|
||||
}
|
||||
const updated = await api.updateConfig(payload);
|
||||
setConfig(updated);
|
||||
setSaveMessage('Calibration saved — control server updated.');
|
||||
setTimeout(() => setSaveMessage(''), 4000);
|
||||
@@ -538,6 +560,20 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="magenta" className="settings-section operator-deck-card operator-interactive calibration-ai-section" style={{ marginBottom: '1rem' }}>
|
||||
<h2 className="font-display">
|
||||
Calibration Control <HelpTip field="calibration_ai_control" />
|
||||
</h2>
|
||||
<p className="section-desc">
|
||||
Choose how the server steers fleet behavior — weighted logic gates or a local LLM loop.
|
||||
</p>
|
||||
<CalibrationAIControl
|
||||
server={s}
|
||||
onUpdate={updateField}
|
||||
/>
|
||||
<FieldHint field="calibration_ai_control" />
|
||||
</NeonCard>
|
||||
|
||||
<div className="settings-grid">
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Deck Atmosphere</h2>
|
||||
|
||||
@@ -302,12 +302,16 @@ export interface ServerSettings {
|
||||
/** When true, Calibrate uses local LLM fleet control instead of logic gates. */
|
||||
ai_control_enabled?: boolean;
|
||||
/** OpenAI-compatible or Ollama base URL on the control PC. */
|
||||
ai_endpoint?: string;
|
||||
/** @deprecated Alias hydrated from legacy saves — prefer ai_endpoint. */
|
||||
ai_local_endpoint?: string;
|
||||
/** LLM model name for fleet AI control. */
|
||||
ai_model?: string;
|
||||
/** Stateless per-cycle decisions — no conversation memory. */
|
||||
ai_no_context?: boolean;
|
||||
/** Seconds between AI decision cycles per agent. */
|
||||
ai_decision_interval_sec?: number;
|
||||
/** @deprecated Alias hydrated from legacy saves — prefer ai_decision_interval_sec. */
|
||||
ai_interval_sec?: number;
|
||||
/** Triple onion recon/deploy gates pushed to agents at auth. */
|
||||
triple_onion_policy?: {
|
||||
|
||||
Reference in New Issue
Block a user