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:
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, "; ")
|
||||
}
|
||||
Reference in New Issue
Block a user