Skip to content

Commit 25b2aa2

Browse files
authored
Merge pull request #141 from flatrun/feat/ai-native
feat: AI-native plan/apply and an interactive assistant
2 parents 0537084 + 6cfe8af commit 25b2aa2

37 files changed

Lines changed: 5719 additions & 204 deletions

config.example.yml

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
deployments_path: ~/flatrun/deployments
1+
deployments_path: /home/nfebe/flatrun/deployments
2+
system_files_root: /
23
docker_socket: unix:///var/run/docker.sock
4+
default_timeout: 2m0s
35
api:
46
host: 0.0.0.0
57
port: 8090
@@ -26,6 +28,7 @@ nginx:
2628
reload_command: nginx -s reload
2729
external: false
2830
container_webroot_path: ""
31+
reject_unknown_domains: false
2932
certbot:
3033
enabled: true
3134
image: certbot/certbot
@@ -35,6 +38,9 @@ certbot:
3538
webroot_path: ""
3639
container_webroot_path: ""
3740
dns_provider: ""
41+
auto_renewal_enabled: false
42+
renewal_threshold_days: 30
43+
renewal_check_interval: 12h0m0s
3844
logging:
3945
level: info
4046
format: json
@@ -58,13 +64,16 @@ infrastructure:
5864
host: ""
5965
port: 6379
6066
password: ""
61-
# cluster:
62-
# enabled: false
63-
# server_name: "" # defaults to OS hostname
64-
# advertise_url: "" # reachable URL for this agent (e.g. https://my-server:8090)
65-
# health_interval: "30s"
66-
# request_timeout: "10s"
67-
67+
powerdns:
68+
enabled: false
69+
container: powerdns
70+
image: powerdns/pdns-auth-48:latest
71+
api_port: 8081
72+
dns_port: 53
73+
api_key: 4bd02ab8c96205b6901660e53f7d96a2bf5253596f378d12
74+
data_path: ""
75+
default_soa: ""
76+
nameservers: ""
6877
security:
6978
enabled: true
7079
realtime_capture: false
@@ -74,8 +83,44 @@ security:
7483
auto_block_enabled: true
7584
auto_block_threshold: 50
7685
auto_block_duration: 24h0m0s
77-
# Only list proxies you control. Forwarded client IPs are honored solely
78-
# when the connecting peer matches; an empty list ignores them, which
79-
# prevents X-Forwarded-For / CF-Connecting-IP spoofing.
86+
detection_window: 2m0s
87+
not_found_threshold: 10
88+
auth_failure_threshold: 5
89+
unique_paths_threshold: 20
90+
repeated_hits_threshold: 30
91+
internal_api_token: ff66c51caad086544e7a6372b681da856146ae5586b6b59408d613fbc85f0045
8092
trusted_proxies: []
8193
trust_cf_header: false
94+
audit:
95+
enabled: false
96+
retention_days: 30
97+
capture_request_body: false
98+
excluded_paths:
99+
- /api/health
100+
sensitive_fields:
101+
- password
102+
- token
103+
- secret
104+
- api_key
105+
- authorization
106+
cleanup_interval: 24h0m0s
107+
cluster:
108+
enabled: false
109+
server_name: nfebe-zenbk-duo
110+
advertise_url: ""
111+
health_interval: 30s
112+
request_timeout: 10s
113+
system_terminal:
114+
protected_mode:
115+
enabled: false
116+
cleanup:
117+
timeout: 2m0s
118+
plans:
119+
ttl: 24h0m0s
120+
retention_days: 30
121+
ai:
122+
enabled: true
123+
base_url: https://generativelanguage.googleapis.com/v1beta/openai/
124+
api_key: AIzaSyCYBIFuQmp35NVnQI68hzlV6l4BSTZ9_lM
125+
model: gemini-2.5-flash
126+
timeout: 1m0s

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/compose-spec/compose-go/v2 v2.10.1
1111
github.com/creack/pty v1.1.24
1212
github.com/digitalocean/godo v1.171.0
13+
github.com/distribution/reference v0.6.0
1314
github.com/docker/docker v28.5.2+incompatible
1415
github.com/fsnotify/fsnotify v1.9.0
1516
github.com/gin-contrib/cors v1.7.6
@@ -67,7 +68,6 @@ require (
6768
github.com/containerd/typeurl/v2 v2.2.3 // indirect
6869
github.com/cpuguy83/dockercfg v0.3.2 // indirect
6970
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
70-
github.com/distribution/reference v0.6.0 // indirect
7171
github.com/docker/buildx v0.31.1 // indirect
7272
github.com/docker/cli v29.2.1+incompatible // indirect
7373
github.com/docker/compose/v5 v5.1.0 // indirect

internal/ai/ai_test.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
package ai
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/flatrun/agent/pkg/config"
13+
)
14+
15+
func TestNewDisabled(t *testing.T) {
16+
if _, err := New(&config.AIConfig{Enabled: false}); err != ErrDisabled {
17+
t.Errorf("err = %v, want ErrDisabled", err)
18+
}
19+
if _, err := New(nil); err != ErrDisabled {
20+
t.Errorf("nil cfg err = %v, want ErrDisabled", err)
21+
}
22+
}
23+
24+
func TestRedactor(t *testing.T) {
25+
r := NewRedactor([]string{"hunter2secret", "short", " spaced-secret-value "})
26+
27+
cases := []struct {
28+
name string
29+
in string
30+
contains []string
31+
excludes []string
32+
minCount int
33+
}{
34+
{
35+
name: "known secret value",
36+
in: "db error: auth failed for password hunter2secret retrying",
37+
excludes: []string{"hunter2secret"},
38+
minCount: 1,
39+
},
40+
{
41+
name: "short values stay",
42+
in: "level=short msg=ok",
43+
contains: []string{"short"},
44+
},
45+
{
46+
name: "credential assignment",
47+
in: "MYSQL_ROOT_PASSWORD=supersafe123\napi_key: abc123def\nDEBUG=true",
48+
contains: []string{"MYSQL_ROOT_PASSWORD=[REDACTED]", "api_key: [REDACTED]", "DEBUG=true"},
49+
excludes: []string{"supersafe123", "abc123def"},
50+
minCount: 2,
51+
},
52+
{
53+
name: "trimmed secret",
54+
in: "token is spaced-secret-value here",
55+
excludes: []string{"spaced-secret-value"},
56+
minCount: 1,
57+
},
58+
}
59+
60+
for _, tc := range cases {
61+
t.Run(tc.name, func(t *testing.T) {
62+
out, count := r.Redact(tc.in)
63+
for _, want := range tc.contains {
64+
if !strings.Contains(out, want) {
65+
t.Errorf("output %q missing %q", out, want)
66+
}
67+
}
68+
for _, banned := range tc.excludes {
69+
if strings.Contains(out, banned) {
70+
t.Errorf("output %q still contains %q", out, banned)
71+
}
72+
}
73+
if count < tc.minCount {
74+
t.Errorf("count = %d, want >= %d", count, tc.minCount)
75+
}
76+
})
77+
}
78+
}
79+
80+
func TestOpenAICompatibleComplete(t *testing.T) {
81+
var gotAuth string
82+
var gotPayload map[string]interface{}
83+
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+
gotAuth = r.Header.Get("Authorization")
85+
if r.URL.Path != "/v1/chat/completions" {
86+
t.Errorf("path = %s", r.URL.Path)
87+
}
88+
_ = json.NewDecoder(r.Body).Decode(&gotPayload)
89+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
90+
"model": "test-model",
91+
"choices": []map[string]interface{}{
92+
{"message": map[string]string{"role": "assistant", "content": "diagnosis here"}},
93+
},
94+
"usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 5},
95+
})
96+
}))
97+
defer fake.Close()
98+
99+
p, err := New(&config.AIConfig{
100+
Enabled: true,
101+
BaseURL: fake.URL + "/v1/",
102+
APIKey: "sk-test",
103+
Model: "test-model",
104+
Timeout: 5 * time.Second,
105+
})
106+
if err != nil {
107+
t.Fatal(err)
108+
}
109+
110+
resp, err := p.Complete(context.Background(), Request{
111+
Messages: []Message{{Role: "user", Content: "hi"}},
112+
})
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
if resp.Content != "diagnosis here" || resp.Model != "test-model" {
117+
t.Errorf("resp = %+v", resp)
118+
}
119+
if resp.Usage.PromptTokens != 10 {
120+
t.Errorf("usage = %+v", resp.Usage)
121+
}
122+
if gotAuth != "Bearer sk-test" {
123+
t.Errorf("auth header = %q", gotAuth)
124+
}
125+
if gotPayload["model"] != "test-model" {
126+
t.Errorf("payload model = %v", gotPayload["model"])
127+
}
128+
}
129+
130+
func TestOpenAICompatibleToolCalling(t *testing.T) {
131+
var sentPayload map[string]interface{}
132+
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133+
_ = json.NewDecoder(r.Body).Decode(&sentPayload)
134+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
135+
"model": "test-model",
136+
"choices": []map[string]interface{}{{
137+
"message": map[string]interface{}{
138+
"role": "assistant",
139+
"content": "",
140+
"tool_calls": []map[string]interface{}{{
141+
"id": "call_1",
142+
"type": "function",
143+
"function": map[string]interface{}{"name": "list_networks", "arguments": "{}"},
144+
}},
145+
},
146+
}},
147+
})
148+
}))
149+
defer fake.Close()
150+
151+
p, _ := New(&config.AIConfig{Enabled: true, BaseURL: fake.URL, Model: "test-model", Timeout: 5 * time.Second})
152+
resp, err := p.Complete(context.Background(), Request{
153+
Messages: []Message{
154+
{Role: "user", Content: "what networks exist?"},
155+
{Role: "assistant", ToolCalls: []ToolCall{{ID: "x", Name: "noop", Arguments: "{}"}}},
156+
{Role: "tool", ToolCallID: "x", Name: "noop", Content: "done"},
157+
},
158+
Tools: []Tool{{
159+
Name: "list_networks",
160+
Description: "List docker networks",
161+
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
162+
}},
163+
})
164+
if err != nil {
165+
t.Fatal(err)
166+
}
167+
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "list_networks" {
168+
t.Fatalf("tool calls = %+v", resp.ToolCalls)
169+
}
170+
171+
tools := sentPayload["tools"].([]interface{})
172+
if len(tools) != 1 {
173+
t.Fatalf("tools not sent: %v", sentPayload["tools"])
174+
}
175+
fn := tools[0].(map[string]interface{})["function"].(map[string]interface{})
176+
if fn["name"] != "list_networks" {
177+
t.Errorf("tool name = %v", fn["name"])
178+
}
179+
180+
// The assistant tool-call message and the tool result must reach the
181+
// wire in OpenAI's nested shape.
182+
msgs := sentPayload["messages"].([]interface{})
183+
assistant := msgs[1].(map[string]interface{})
184+
if _, ok := assistant["tool_calls"]; !ok {
185+
t.Error("assistant tool_calls not serialized")
186+
}
187+
toolMsg := msgs[2].(map[string]interface{})
188+
if toolMsg["tool_call_id"] != "x" || toolMsg["role"] != "tool" {
189+
t.Errorf("tool result message = %v", toolMsg)
190+
}
191+
}
192+
193+
func TestOpenAICompatibleKeyless(t *testing.T) {
194+
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195+
if auth := r.Header.Get("Authorization"); auth != "" {
196+
t.Errorf("keyless request sent auth header %q", auth)
197+
}
198+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
199+
"choices": []map[string]interface{}{{"message": map[string]string{"content": "ok"}}},
200+
})
201+
}))
202+
defer fake.Close()
203+
204+
p, _ := New(&config.AIConfig{Enabled: true, BaseURL: fake.URL, Model: "llama3"})
205+
resp, err := p.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "hi"}}})
206+
if err != nil {
207+
t.Fatal(err)
208+
}
209+
if resp.Model != "llama3" {
210+
t.Errorf("model fallback = %q, want configured model", resp.Model)
211+
}
212+
}
213+
214+
func TestOpenAICompatibleErrorMapping(t *testing.T) {
215+
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216+
w.WriteHeader(http.StatusUnauthorized)
217+
_, _ = w.Write([]byte(`{"error":{"message":"invalid api key"}}`))
218+
}))
219+
defer fake.Close()
220+
221+
p, _ := New(&config.AIConfig{Enabled: true, BaseURL: fake.URL, Model: "m"})
222+
_, err := p.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "hi"}}})
223+
if err == nil || !strings.Contains(err.Error(), "invalid api key") || !strings.Contains(err.Error(), "401") {
224+
t.Errorf("err = %v, want provider message and status", err)
225+
}
226+
}
227+
228+
func TestBuildAssistMessagesTruncates(t *testing.T) {
229+
long := strings.Repeat("x", contextBudget*2)
230+
intent, ok := GetIntent("diagnose")
231+
if !ok {
232+
t.Fatal("diagnose intent missing")
233+
}
234+
msgs := BuildAssistMessages(intent, "deployment myapp", []Section{
235+
{Label: "docker-compose.yml", Content: "services: {}", Format: "yaml"},
236+
{Label: "Recent logs", Content: long},
237+
}, "why does it crash?", "https://flatrun.dev/docs/")
238+
239+
if len(msgs) != 2 {
240+
t.Fatalf("got %d messages", len(msgs))
241+
}
242+
if msgs[0].Role != "system" || msgs[1].Role != "user" {
243+
t.Errorf("roles = %s/%s", msgs[0].Role, msgs[1].Role)
244+
}
245+
if len(msgs[1].Content) > contextBudget+2000 {
246+
t.Errorf("user message not truncated: %d chars", len(msgs[1].Content))
247+
}
248+
if !strings.Contains(msgs[1].Content, "[... truncated ...]") {
249+
t.Error("truncation marker missing")
250+
}
251+
if !strings.Contains(msgs[1].Content, strings.Repeat("x", 100)) {
252+
t.Error("log tail missing from prompt")
253+
}
254+
if !strings.Contains(msgs[1].Content, "why does it crash?") {
255+
t.Error("operator question missing from prompt")
256+
}
257+
if !strings.Contains(msgs[1].Content, "deployment myapp") {
258+
t.Error("scope label missing from prompt")
259+
}
260+
if !strings.Contains(msgs[0].Content, "https://flatrun.dev/docs/") {
261+
t.Error("docs link missing from system prompt")
262+
}
263+
}
264+
265+
func TestIntentRegistry(t *testing.T) {
266+
for _, key := range []string{"diagnose", "improve", "secure", "explain"} {
267+
intent, ok := GetIntent(key)
268+
if !ok {
269+
t.Errorf("intent %q missing", key)
270+
continue
271+
}
272+
msgs := BuildAssistMessages(intent, "the FlatRun host", []Section{{Label: "Output", Content: "boom"}}, "", "")
273+
hasSuggestionFormat := strings.Contains(msgs[0].Content, "suggestions")
274+
if intent.AllowSuggestions && !hasSuggestionFormat {
275+
t.Errorf("intent %q should request suggestions", key)
276+
}
277+
if !intent.AllowSuggestions && hasSuggestionFormat {
278+
t.Errorf("intent %q should not request suggestions", key)
279+
}
280+
}
281+
if _, ok := GetIntent("nonsense"); ok {
282+
t.Error("unknown intent should not resolve")
283+
}
284+
}

0 commit comments

Comments
 (0)