mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 12:40:55 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac6e2b3c44 | ||
|
|
97b0fdced6 |
@@ -4,6 +4,10 @@ All notable changes to this project are documented in this file. The format is b
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Noise profile at runtime**: System settings has a **Target noise** card. It sets the profile (`off`, `quiet`, `balanced`, `aggressive`) through the `noise_profile` setting, which takes precedence over `ARTEX_NOISE_PROFILE`. The change applies to the next WebFetch, http custom tool and enrich request, and to the next agent prompt, without a restart. The card also shows the per-host counters, which `GET /api/settings/noise` returns.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Untrusted-output boundary is now on by default**: Norma frames tool results for the worker, planner, chat and main agent unless `ARTEX_UNTRUSTED_BOUNDARY` is set to `0`, `false`, `off` or `no`. It was opt-in via `ARTEX_UNTRUSTED_BOUNDARY=1` in 0.6.0 (#166).
|
||||
|
||||
@@ -407,6 +407,8 @@ in-flight GETs and caches them briefly (60/30/10 s; never POST/PUT/PATCH/DELETE)
|
||||
per-host noise line each minute. `ARTEX_TARGET_USER_AGENT` sets a User-Agent on target requests
|
||||
that carry none. Bash-launched tools are not paced yet; the low-and-slow prompt block supplies
|
||||
their rate flags.
|
||||
The **Target noise** card in System settings sets the profile at runtime (no restart) and shows the
|
||||
per-host counters; once saved, that setting takes precedence over `ARTEX_NOISE_PROFILE`.
|
||||
|
||||
**Low-and-slow agent guidance (off by default)**: when a noise profile is set, the planner, worker,
|
||||
main agent and chat agents receive a code-owned prompt block with the profile's per-host limits and
|
||||
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
// prompts stay byte-for-byte unchanged by default.
|
||||
const noiseProfileHeader = "[Noise profile: "
|
||||
|
||||
// noiseProfileBlock returns the low-and-slow guidance for the active noise
|
||||
// profile (ARTEX_NOISE_PROFILE), or "" when the profile is off. The text is
|
||||
// code-owned and carries no target data.
|
||||
func noiseProfileBlock() string { return renderNoiseProfile(egress.ProfileFromEnv()) }
|
||||
// noiseProfileBlock returns the low-and-slow guidance for the current noise
|
||||
// profile (the noise_profile setting, else ARTEX_NOISE_PROFILE), or "" when the
|
||||
// profile is off. It is read at each prompt build, so a changed setting shows in
|
||||
// the next prompt. The text is code-owned and carries no target data.
|
||||
func noiseProfileBlock() string { return renderNoiseProfile(egress.CurrentProfile()) }
|
||||
|
||||
// renderNoiseProfile renders the block for one profile. It is pure: tests cover
|
||||
// every profile without touching the environment.
|
||||
|
||||
+15
-10
@@ -59,18 +59,23 @@ func TestPromptBuildersNoiseBlock(t *testing.T) {
|
||||
"mainagent": func() string { return mainAgentSystem("goal", "/data", "/work") },
|
||||
"chat": func() string { return chatSystem("auto", "/data", "/work") },
|
||||
}
|
||||
for _, env := range []string{"", "off"} {
|
||||
t.Setenv("ARTEX_NOISE_PROFILE", env)
|
||||
prev := egress.CurrentProfile()
|
||||
t.Cleanup(func() { egress.SetProfile(prev) })
|
||||
// The block follows the current profile, not the environment.
|
||||
t.Setenv("ARTEX_NOISE_PROFILE", "aggressive")
|
||||
egress.SetProfile(egress.Profile{Name: "off"})
|
||||
for name, build := range builders {
|
||||
if got := build(); strings.Contains(got, noiseProfileHeader) {
|
||||
t.Errorf("%s with profile off contains the noise block", name)
|
||||
}
|
||||
}
|
||||
for _, profile := range []string{"quiet", "balanced"} {
|
||||
egress.SetProfile(egress.Profiles[profile])
|
||||
for name, build := range builders {
|
||||
if got := build(); strings.Contains(got, noiseProfileHeader) {
|
||||
t.Errorf("%s with profile %q contains the noise block", name, env)
|
||||
got := build()
|
||||
if !strings.Contains(got, noiseProfileHeader+profile) || strings.Contains(got, noiseProfileHeader+"aggressive") {
|
||||
t.Errorf("%s after SetProfile(%s) is missing its block or shows the env profile", name, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Setenv("ARTEX_NOISE_PROFILE", "quiet")
|
||||
for name, build := range builders {
|
||||
if got := build(); !strings.Contains(got, noiseProfileHeader+"quiet") {
|
||||
t.Errorf("%s with quiet is missing the noise block", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -27,11 +27,12 @@ func withWebFetch(ctx context.Context, tools []actool.CoreTool, proxy, caCert st
|
||||
gated := scoped && scope.Enabled != nil && scope.Enabled()
|
||||
webFetch := actool.NewWebFetch(actool.WebFetchConfig{Proxy: proxy, CACert: caCert, CheckRedirect: intercept.RedirectCheck("WebFetch"),
|
||||
DialContext: intercept.PinnedDialer("WebFetch", scoped), DisableDirectRetry: gated})
|
||||
return append(tools[:len(tools):len(tools)], governedFetch{webFetch, egressGovernor()})
|
||||
return append(tools[:len(tools):len(tools)], governedFetch{webFetch})
|
||||
}
|
||||
|
||||
// egressGovernor is the governor of WebFetch; a test may pin it.
|
||||
var egressGovernor = egress.Shared
|
||||
// egressGovernor resolves the governor of a WebFetch call, once per call so a
|
||||
// changed noise profile applies to the next call; a test may pin it.
|
||||
var egressGovernor = egress.Current
|
||||
|
||||
// governedFetch runs each WebFetch call as one send under the egress governor
|
||||
// (off by default; then the call runs unchanged). Under a noise profile it
|
||||
@@ -42,7 +43,6 @@ var egressGovernor = egress.Shared
|
||||
// 429/503 is not retried.
|
||||
type governedFetch struct {
|
||||
actool.CoreTool
|
||||
g *egress.Governor
|
||||
}
|
||||
|
||||
func (f governedFetch) Call(ctx context.Context, input json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
|
||||
@@ -56,7 +56,7 @@ func (f governedFetch) Call(ctx context.Context, input json.RawMessage, tc *acto
|
||||
if err != nil || u.Host == "" {
|
||||
return f.CoreTool.Call(ctx, input, tc) // the tool reports the bad URL
|
||||
}
|
||||
return egress.Do(f.g, ctx, u, func() (actool.Result, error) {
|
||||
return egress.Do(egressGovernor(), ctx, u, func() (actool.Result, error) {
|
||||
return f.CoreTool.Call(ctx, input, tc)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ func TestWebFetchProfileDoesNotCache(t *testing.T) {
|
||||
checks.Add(1)
|
||||
return nil
|
||||
}})
|
||||
tool := governedFetch{inner, egress.New(egress.Profiles["balanced"])}
|
||||
balanced := egress.New(egress.Profiles["balanced"])
|
||||
old := egressGovernor
|
||||
egressGovernor = func() *egress.Governor { return balanced }
|
||||
t.Cleanup(func() { egressGovernor = old })
|
||||
tool := governedFetch{inner}
|
||||
ctx := intercept.WithTargetScope(t.Context(), intercept.TargetScope{TaskID: "7"})
|
||||
for range 2 {
|
||||
res, err := tool.Call(ctx, json.RawMessage(fetchInputJSON(pair.start)), &actool.ToolContext{})
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package egress
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// keepProfile restores the process profile after a test that changes it.
|
||||
func keepProfile(t *testing.T) {
|
||||
prev := CurrentProfile()
|
||||
t.Cleanup(func() { SetProfile(prev) })
|
||||
}
|
||||
|
||||
func TestSetProfileConcurrentAndConsistent(t *testing.T) {
|
||||
keepProfile(t)
|
||||
names := []string{"off", "quiet", "balanced", "aggressive"}
|
||||
var wg sync.WaitGroup
|
||||
for i := range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := range 200 {
|
||||
p, _ := ProfileByName(names[(i+j)%len(names)])
|
||||
SetProfile(p)
|
||||
if g := Current(); g == nil || g.Profile().Name == "" {
|
||||
t.Errorf("Current() = %v during swaps", g)
|
||||
return
|
||||
}
|
||||
_ = CurrentProfile()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, name := range names {
|
||||
p, _ := ProfileByName(name)
|
||||
g := SetProfile(p)
|
||||
if Current() != g || CurrentProfile().Name != name || Current().Profile() != CurrentProfile() {
|
||||
t.Fatalf("after SetProfile(%s): Current=%p want %p, CurrentProfile=%q", name, Current(), g, CurrentProfile().Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A request in flight keeps the governor it started on: its release frees the
|
||||
// old governor's slots, and the new governor starts with no counter.
|
||||
func TestSetProfileInFlightFinishesOnOldGovernor(t *testing.T) {
|
||||
keepProfile(t)
|
||||
old := SetProfile(open)
|
||||
release, err := old.acquire(t.Context(), "a.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := SetProfile(Profiles["balanced"])
|
||||
if Current() != fresh || len(fresh.Snapshot()) != 0 {
|
||||
t.Fatalf("new governor: current=%p fresh=%p snapshot=%v", Current(), fresh, fresh.Snapshot())
|
||||
}
|
||||
if s := old.Snapshot(); len(s) != 1 || s[0].Inflight != 1 {
|
||||
t.Fatalf("old governor before release: %+v", s)
|
||||
}
|
||||
release()
|
||||
if s := old.Snapshot(); len(s) != 1 || s[0].Inflight != 0 || s[0].RPM != 1 || s[0].PeakInflight != 1 {
|
||||
t.Fatalf("old governor after release: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
type recordRT struct{ got *http.Request }
|
||||
|
||||
func (r *recordRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
r.got = req
|
||||
return &http.Response{StatusCode: 204, Body: http.NoBody, Request: req}, nil
|
||||
}
|
||||
|
||||
// Off stays a strict passthrough, through Transport and Dynamic alike.
|
||||
func TestOffIsPassthroughAfterSetProfile(t *testing.T) {
|
||||
keepProfile(t)
|
||||
t.Setenv("ARTEX_TARGET_USER_AGENT", "artex-test/1")
|
||||
off, ok := ProfileByName("OFF")
|
||||
if !ok || off.Name != "off" || off.UserAgent != "" {
|
||||
t.Fatalf("ProfileByName(OFF) = %+v %v", off, ok)
|
||||
}
|
||||
SetProfile(off)
|
||||
base := &recordRT{}
|
||||
if tr := Current().Transport("x", base); tr != http.RoundTripper(base) {
|
||||
t.Fatalf("off Transport = %T, want the base itself", tr)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "http://a.example/", nil)
|
||||
if _, err := Dynamic("x", base).RoundTrip(req); err != nil || base.got != req || req.Header.Get("User-Agent") != "" {
|
||||
t.Fatalf("off Dynamic: err=%v same request=%v UA=%q", err, base.got == req, req.Header.Get("User-Agent"))
|
||||
}
|
||||
if len(Current().Snapshot()) != 0 {
|
||||
t.Fatalf("off counted a request: %v", Current().Snapshot())
|
||||
}
|
||||
// Any other profile carries the user agent.
|
||||
if p, _ := ProfileByName("balanced"); p.UserAgent != "artex-test/1" {
|
||||
t.Fatalf("balanced UserAgent = %q", p.UserAgent)
|
||||
}
|
||||
if _, ok := ProfileByName(""); ok {
|
||||
t.Fatal(`ProfileByName("") accepted`)
|
||||
}
|
||||
if _, ok := ProfileByName("loud"); ok {
|
||||
t.Fatal(`ProfileByName("loud") accepted`)
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic resolves the governor per request: a client built before
|
||||
// SetProfile is paced by the new profile.
|
||||
func TestDynamicFollowsSetProfile(t *testing.T) {
|
||||
keepProfile(t)
|
||||
srv := newServer(t, 0, nil)
|
||||
SetProfile(Profile{Name: "off"})
|
||||
client := &http.Client{Transport: Dynamic("ns", nil)}
|
||||
get := func() {
|
||||
t.Helper()
|
||||
resp, err := client.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
get()
|
||||
g := SetProfile(open)
|
||||
get()
|
||||
get()
|
||||
if s := g.Snapshot(); len(s) != 1 || s[0].RPM != 2 {
|
||||
t.Fatalf("snapshot after switch: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot reads the counters without starting a new minute; report does.
|
||||
func TestSnapshotDoesNotReset(t *testing.T) {
|
||||
g := New(open)
|
||||
srv := newServer(t, 0, nil)
|
||||
tr := g.Transport("ns", nil)
|
||||
for range 3 {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
resp, err := tr.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
want := HostNoise{Host: HostKey(srvURL(t, srv.URL)), RPM: 3, PeakRPM: 3, PeakInflight: 1}
|
||||
for range 2 {
|
||||
if s := g.Snapshot(); len(s) != 1 || s[0] != want {
|
||||
t.Fatalf("snapshot = %+v, want [%+v]", s, want)
|
||||
}
|
||||
}
|
||||
g.report()
|
||||
if s := g.Snapshot(); len(s) != 1 || s[0].RPM != 0 || s[0].PeakRPM != 3 {
|
||||
t.Fatalf("snapshot after report = %+v, want rpm 0 and peak 3", s)
|
||||
}
|
||||
}
|
||||
|
||||
// SetProfile never starts a goroutine of its own: one ticker serves every
|
||||
// governor.
|
||||
func TestSetProfileStartsNoGoroutine(t *testing.T) {
|
||||
keepProfile(t)
|
||||
SetProfile(Profiles["quiet"]) // the reporter is running from here on
|
||||
before := runtime.NumGoroutine()
|
||||
for range 50 {
|
||||
SetProfile(Profiles["balanced"])
|
||||
SetProfile(Profile{Name: "off"})
|
||||
}
|
||||
if after := runtime.NumGoroutine(); after > before {
|
||||
t.Fatalf("goroutines %d -> %d after 100 SetProfile calls", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func srvURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
+124
-22
@@ -1,6 +1,7 @@
|
||||
// Package egress is the governor the platform-owned HTTP requests to a target
|
||||
// pass through (WebFetch, the http custom tools, the enrich probe). A noise
|
||||
// profile (ARTEX_NOISE_PROFILE) sets every lever: a token bucket, a jitter and a
|
||||
// profile (the noise_profile setting, else ARTEX_NOISE_PROFILE; SetProfile
|
||||
// changes it at runtime) sets every lever: a token bucket, a jitter and a
|
||||
// concurrency cap per host, a global concurrency cap, backoff on 429/503, and,
|
||||
// for the GET requests of one task, coalescing and a short cache. The default
|
||||
// profile, off, is a strict passthrough: requests and timing are unchanged.
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
@@ -54,42 +56,91 @@ var Profiles = map[string]Profile{
|
||||
"aggressive": {Name: "aggressive", RPS: 20, Burst: 40, MaxPerHost: 4, MaxGlobal: 32, Jitter: 0.1, CacheTTL: 10 * time.Second, MaxRetries: 2, BaseDelay: 500 * time.Millisecond, MaxDelay: 10 * time.Second},
|
||||
}
|
||||
|
||||
// ProfileFromEnv returns the profile ARTEX_NOISE_PROFILE names; unset, "off" or
|
||||
// unknown is off.
|
||||
func ProfileFromEnv() Profile {
|
||||
name := strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_NOISE_PROFILE")))
|
||||
p, ok := Profiles[name]
|
||||
if !ok {
|
||||
if name != "" && name != "off" {
|
||||
log.Printf("[egress] ARTEX_NOISE_PROFILE=%q is not off, quiet, balanced or aggressive; using off", name)
|
||||
}
|
||||
return Profile{Name: "off"}
|
||||
// ProfileByName returns the profile name names (off, quiet, balanced or
|
||||
// aggressive, any case); ok is false for any other name, "" included. A profile
|
||||
// other than off carries ARTEX_TARGET_USER_AGENT; off stays a strict passthrough.
|
||||
func ProfileByName(name string) (p Profile, ok bool) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "off" {
|
||||
return Profile{Name: "off"}, true
|
||||
}
|
||||
if p, ok = Profiles[name]; !ok {
|
||||
return Profile{Name: "off"}, false
|
||||
}
|
||||
if ua := strings.TrimSpace(os.Getenv("ARTEX_TARGET_USER_AGENT")); strings.ContainsFunc(ua, unicode.IsControl) {
|
||||
log.Printf("[egress] ARTEX_TARGET_USER_AGENT contains a control character; ignored")
|
||||
} else {
|
||||
p.UserAgent = ua
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// ProfileFromEnv returns the profile ARTEX_NOISE_PROFILE names; unset, "off" or
|
||||
// unknown is off.
|
||||
func ProfileFromEnv() Profile {
|
||||
name := strings.TrimSpace(os.Getenv("ARTEX_NOISE_PROFILE"))
|
||||
p, ok := ProfileByName(name)
|
||||
if !ok && name != "" {
|
||||
log.Printf("[egress] ARTEX_NOISE_PROFILE=%q is not off, quiet, balanced or aggressive; using off", name)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Shared is the one governor of the process, built from the environment on
|
||||
// first use; WebFetch, the http custom tools and the enrich probe all use it.
|
||||
// Under a profile it logs the noise counter of each host once a minute.
|
||||
var Shared = sync.OnceValue(func() *Governor {
|
||||
g := New(ProfileFromEnv())
|
||||
// current is the governor new requests use. SetProfile replaces it; a request
|
||||
// already in flight keeps the governor it started on, so its slots are
|
||||
// released where they were taken.
|
||||
var (
|
||||
current atomic.Pointer[Governor]
|
||||
initCurrent sync.Once
|
||||
reporterOnce sync.Once
|
||||
)
|
||||
|
||||
// Current returns the governor of the process: the one SetProfile published
|
||||
// last, or, before any SetProfile, one built from the environment on first use.
|
||||
// WebFetch, the http custom tools and the enrich probe resolve it per request.
|
||||
func Current() *Governor {
|
||||
if g := current.Load(); g != nil {
|
||||
return g
|
||||
}
|
||||
initCurrent.Do(func() {
|
||||
g := New(ProfileFromEnv())
|
||||
if current.CompareAndSwap(nil, g) {
|
||||
log.Printf("[egress] noise profile: %s", g.p.Name)
|
||||
}
|
||||
startReporter()
|
||||
})
|
||||
return current.Load()
|
||||
}
|
||||
|
||||
// CurrentProfile is the profile of Current.
|
||||
func CurrentProfile() Profile { return Current().p }
|
||||
|
||||
// SetProfile builds a governor for p and publishes it: new requests use it,
|
||||
// requests in flight finish on the previous one. The counters start afresh.
|
||||
func SetProfile(p Profile) *Governor {
|
||||
g := New(p)
|
||||
current.Store(g)
|
||||
log.Printf("[egress] noise profile: %s", g.p.Name)
|
||||
if !g.off() {
|
||||
startReporter()
|
||||
return g
|
||||
}
|
||||
|
||||
// startReporter starts the one package-level ticker that logs, once a minute,
|
||||
// the noise counter of each host of the current governor. SetProfile never
|
||||
// starts another.
|
||||
func startReporter() {
|
||||
reporterOnce.Do(func() {
|
||||
go func() {
|
||||
for range time.Tick(time.Minute) {
|
||||
for _, l := range g.report() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
for _, l := range Current().report() {
|
||||
log.Print(l)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
return g
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Governor holds the per-host state and the per-task response cache.
|
||||
type Governor struct {
|
||||
@@ -123,6 +174,9 @@ func New(p Profile) *Governor {
|
||||
|
||||
func (g *Governor) off() bool { return g.p.RPS <= 0 }
|
||||
|
||||
// Profile is the profile g was built for.
|
||||
func (g *Governor) Profile() Profile { return g.p }
|
||||
|
||||
// HostKey is the lowercased host, with the port when it is not the default.
|
||||
func HostKey(u *url.URL) string {
|
||||
h, p := strings.ToLower(u.Hostname()), u.Port()
|
||||
@@ -240,6 +294,35 @@ func (g *Governor) report() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// HostNoise is the noise counter of one host: the current minute (RPM,
|
||||
// DuplicatesAvoided, PeakInflight), the peak per minute so far, and the
|
||||
// requests in flight now.
|
||||
type HostNoise struct {
|
||||
Host string `json:"host"`
|
||||
RPM int `json:"rpm"`
|
||||
PeakRPM int `json:"peak_rpm"`
|
||||
PeakInflight int `json:"peak_inflight"`
|
||||
Inflight int `json:"inflight"`
|
||||
DuplicatesAvoided int `json:"duplicates_avoided"`
|
||||
}
|
||||
|
||||
// Snapshot returns the counter of every host that has sent or deduplicated a
|
||||
// request, sorted by host. Unlike report it starts no new minute.
|
||||
func (g *Governor) Snapshot() []HostNoise {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
out := []HostNoise{}
|
||||
for key, h := range g.hosts {
|
||||
if h.sent == 0 && h.dupes == 0 && h.inflight == 0 && h.peakRPM == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, HostNoise{Host: key, RPM: h.sent, PeakRPM: max(h.peakRPM, h.sent),
|
||||
PeakInflight: h.peakInflight, Inflight: h.inflight, DuplicatesAvoided: h.dupes})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
|
||||
return out
|
||||
}
|
||||
|
||||
var errNotShared = errors.New("egress: response not shared")
|
||||
|
||||
// share runs fn once for the concurrent callers of key and keeps its value for
|
||||
@@ -344,6 +427,25 @@ type transport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
// Dynamic returns base (nil: http.DefaultTransport) governed by the governor
|
||||
// current at each request, for a client built once and kept: a later
|
||||
// SetProfile applies to its next request. Off, the request goes to base as is.
|
||||
func Dynamic(namespace string, base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
return dynamicTransport{namespace, base}
|
||||
}
|
||||
|
||||
type dynamicTransport struct {
|
||||
namespace string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d dynamicTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return Current().Transport(d.namespace, d.base).RoundTrip(req)
|
||||
}
|
||||
|
||||
const maxSharedBody = 2 << 20 // a larger response is neither coalesced nor cached
|
||||
|
||||
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
+3
-2
@@ -99,7 +99,8 @@ func New(as *db.AssetStore, proxy func() string, workers int) *Engine {
|
||||
// probe without a scope is refused. When the scope's gate is on, the probe
|
||||
// connects to the target itself, without the proxy, so that the dialer decides
|
||||
// on the target and connects only to the addresses a pin covers. Every request
|
||||
// goes through the shared egress governor (the noise profile; off by default).
|
||||
// goes through the egress governor current at that request (the noise profile;
|
||||
// off by default), so a changed profile applies without rebuilding the client.
|
||||
func buildClient(proxy func() string) *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
@@ -120,7 +121,7 @@ func buildClient(proxy func() string) *http.Client {
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: egress.Shared().Transport("enrich", tr),
|
||||
Transport: egress.Dynamic("enrich", tr),
|
||||
Timeout: httpTimeout,
|
||||
// cap redirects; keep them within scope by re-checking at probe time
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
|
||||
@@ -566,13 +566,14 @@ func (s *Server) runHTTPTool(ctx context.Context, key string, execRaw json.RawMe
|
||||
client := &http.Client{Timeout: timeoutOr(spec.TimeoutMs, 30000)}
|
||||
// Every redirect hop goes through the target gate, as the rendered URL did.
|
||||
client.CheckRedirect = intercept.RedirectCheck(key)
|
||||
// The shared egress governor paces every request and redirect hop per host
|
||||
// under the noise profile (off by default: the base transport itself).
|
||||
// The current egress governor paces every request and redirect hop per host
|
||||
// under the noise profile (off by default: the base transport itself). It is
|
||||
// resolved per request, so a changed profile applies to the next call.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
if tr := s.httpToolTransport(ctx, key, spec); tr != nil {
|
||||
base = tr
|
||||
}
|
||||
client.Transport = egress.Shared().Transport(key, base)
|
||||
client.Transport = egress.Current().Transport(key, base)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return actool.Errorf("request failed: " + err.Error()), nil
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/config"
|
||||
pgdb "github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
"github.com/Autumn-27/artex/enrich"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
@@ -304,6 +305,9 @@ const (
|
||||
// when on, the four agent kinds wired into the platform (planner/worker/main
|
||||
// agent/chat) have context compaction taken over by noa, replacing the built-in compaction. Read once per run; toggling affects only runs started afterwards.
|
||||
settingNoaCompaction = "noa_compaction"
|
||||
// Target noise profile (off|quiet|balanced|aggressive). When stored it wins over
|
||||
// ARTEX_NOISE_PROFILE, which stays the fallback; applied at startup and on PUT.
|
||||
settingNoiseProfile = "noise_profile"
|
||||
// defaultWebSearchBackend is used when web search is on but no backend was picked.
|
||||
defaultWebSearchBackend = "ddgs"
|
||||
// deepSeekWebSearchBackend borrows the active LLM profile instead of its own
|
||||
@@ -401,6 +405,7 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
||||
pg.Close()
|
||||
return nil, fmt.Errorf("recover finding retests: %w", err)
|
||||
}
|
||||
egress.SetProfile(startupNoiseProfile(pg))
|
||||
if err := pg.EnsureLLMRecordsTable(); err != nil {
|
||||
log.Printf("[llmrec] create table: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
)
|
||||
|
||||
// startupNoiseProfile is the noise profile to apply at startup: the stored
|
||||
// noise_profile setting when there is a valid one, else ARTEX_NOISE_PROFILE.
|
||||
func startupNoiseProfile(pg *db.DB) egress.Profile {
|
||||
v, ok, err := pg.GetSetting(settingNoiseProfile)
|
||||
if err != nil {
|
||||
log.Printf("[egress] read %s setting: %v; using ARTEX_NOISE_PROFILE", settingNoiseProfile, err)
|
||||
}
|
||||
if ok {
|
||||
if p, valid := egress.ProfileByName(v); valid {
|
||||
return p
|
||||
}
|
||||
log.Printf("[egress] stored %s=%q is not off, quiet, balanced or aggressive; using ARTEX_NOISE_PROFILE", settingNoiseProfile, v)
|
||||
}
|
||||
return egress.ProfileFromEnv()
|
||||
}
|
||||
|
||||
// noiseStatus returns the current noise profile and the per-host counters of
|
||||
// its governor, read without starting a new minute.
|
||||
func (s *Server) noiseStatus(w http.ResponseWriter, r *http.Request) {
|
||||
g := egress.Current()
|
||||
writeJSON(w, 200, map[string]any{"profile": g.Profile().Name, "hosts": g.Snapshot()})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
)
|
||||
|
||||
func TestNoiseProfileSettingHTTP(t *testing.T) {
|
||||
skipWithoutDSN(t)
|
||||
prev := egress.CurrentProfile()
|
||||
t.Cleanup(func() { egress.SetProfile(prev) })
|
||||
t.Setenv("ARTEX_NOISE_PROFILE", "quiet")
|
||||
t.Setenv("ARTEX_TARGET_USER_AGENT", "artex-noise-test/1")
|
||||
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
clear := func() {
|
||||
if _, err := m.pg.Exec(`DELETE FROM settings WHERE key=$1`, settingNoiseProfile); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
clear()
|
||||
defer clear() // before m.Close
|
||||
|
||||
// Precedence: no setting -> env; a valid setting wins; an invalid one falls back to env.
|
||||
if p := startupNoiseProfile(m.pg); p.Name != "quiet" {
|
||||
t.Fatalf("no setting: %q, want env quiet", p.Name)
|
||||
}
|
||||
if err := m.pg.SetSetting(settingNoiseProfile, "balanced"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2.Close()
|
||||
if p := egress.CurrentProfile(); p.Name != "balanced" || p.UserAgent != "artex-noise-test/1" {
|
||||
t.Fatalf("NewManager with setting balanced applied %+v", p)
|
||||
}
|
||||
if err := m.pg.SetSetting(settingNoiseProfile, "loud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := startupNoiseProfile(m.pg); p.Name != "quiet" {
|
||||
t.Fatalf("invalid setting: %q, want env quiet", p.Name)
|
||||
}
|
||||
if err := m.pg.SetSetting(settingNoiseProfile, "balanced"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
do := func(method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
egress.SetProfile(egress.Profiles["balanced"])
|
||||
if code, out := do(http.MethodGet, "/api/settings", ""); code != 200 || out["noise_profile"] != "balanced" {
|
||||
t.Fatalf("GET settings: %d noise_profile=%v", code, out["noise_profile"])
|
||||
}
|
||||
if code, out := do(http.MethodPut, "/api/settings", `{"noise_profile":"Aggressive"}`); code != 200 || out["noise_profile"] != "aggressive" {
|
||||
t.Fatalf("PUT aggressive: %d noise_profile=%v", code, out["noise_profile"])
|
||||
}
|
||||
if v, _, _ := m.pg.GetSetting(settingNoiseProfile); v != "aggressive" || egress.CurrentProfile().Name != "aggressive" {
|
||||
t.Fatalf("after PUT: stored %q, current %q", v, egress.CurrentProfile().Name)
|
||||
}
|
||||
for _, bad := range []string{`{"noise_profile":"loud"}`, `{"noise_profile":""}`, `{"noise_profile":1}`} {
|
||||
if code, _ := do(http.MethodPut, "/api/settings", bad); code != 400 {
|
||||
t.Errorf("PUT %s: %d, want 400", bad, code)
|
||||
}
|
||||
}
|
||||
if code, out := do(http.MethodPut, "/api/settings", `{"noise_profile":null}`); code != 200 || out["noise_profile"] != "aggressive" {
|
||||
t.Fatalf("PUT null: %d noise_profile=%v, want unchanged", code, out["noise_profile"])
|
||||
}
|
||||
if v, _, _ := m.pg.GetSetting(settingNoiseProfile); v != "aggressive" || egress.CurrentProfile().Name != "aggressive" {
|
||||
t.Fatalf("after invalid and null PUTs: stored %q, current %q", v, egress.CurrentProfile().Name)
|
||||
}
|
||||
|
||||
// The noise endpoint reads the current governor's counters.
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer target.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, target.URL, nil)
|
||||
resp, err := egress.Current().Transport("noise-test", nil).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
code, out := do(http.MethodGet, "/api/settings/noise", "")
|
||||
hosts, _ := out["hosts"].([]any)
|
||||
if code != 200 || out["profile"] != "aggressive" || len(hosts) != 1 {
|
||||
t.Fatalf("GET noise: %d %v", code, out)
|
||||
}
|
||||
host, _ := hosts[0].(map[string]any)
|
||||
for _, key := range []string{"host", "rpm", "peak_rpm", "peak_inflight", "inflight", "duplicates_avoided"} {
|
||||
if _, ok := host[key]; !ok {
|
||||
t.Errorf("noise host misses %q: %v", key, host)
|
||||
}
|
||||
}
|
||||
if host["host"] != strings.TrimPrefix(target.URL, "http://") || host["rpm"] != float64(1) || host["inflight"] != float64(0) {
|
||||
t.Fatalf("noise host = %v", host)
|
||||
}
|
||||
// A second read does not reset the minute.
|
||||
if _, again := do(http.MethodGet, "/api/settings/noise", ""); again["hosts"].([]any)[0].(map[string]any)["rpm"] != float64(1) {
|
||||
t.Fatalf("second GET noise reset the counter: %v", again)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/artex/llmpool"
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
@@ -833,6 +834,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord)
|
||||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||||
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
||||
mux.HandleFunc("GET /api/settings/noise", s.noiseStatus) // current noise profile + per-host counters (no reset)
|
||||
// Vulnerability IM push. Channels are "multi-instance + per-instance filter rules" resources, so they get their own group of
|
||||
// REST endpoints instead of being stuffed into the flat /api/settings key-value store.
|
||||
mux.HandleFunc("GET /api/notify/meta", s.notifyMeta)
|
||||
@@ -3528,6 +3530,8 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
// Render limits of IM messages; 0 = no truncation.
|
||||
"notify_summary_max_runes": limits.SummaryRunes,
|
||||
"notify_assets_max_shown": limits.AssetsShown,
|
||||
// Effective target noise profile (off by default): the stored setting, else ARTEX_NOISE_PROFILE.
|
||||
"noise_profile": egress.CurrentProfile().Name,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3603,11 +3607,27 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
// Render limits of IM messages: summary characters (0-4000) and asset names listed (0-50); 0 = no truncation.
|
||||
NotifySummaryMaxRunes *int `json:"notify_summary_max_runes"`
|
||||
NotifyAssetsMaxShown *int `json:"notify_assets_max_shown"`
|
||||
// Target noise profile: off|quiet|balanced|aggressive; null = unchanged. Applies to the next request, no agent rebuild.
|
||||
NoiseProfile *string `json:"noise_profile"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
// Validated before any other field is written, so a bad value changes nothing.
|
||||
var noise egress.Profile
|
||||
if req.NoiseProfile != nil {
|
||||
var ok bool
|
||||
if noise, ok = egress.ProfileByName(*req.NoiseProfile); !ok {
|
||||
writeErr(w, 400, "noise_profile must be off, quiet, balanced or aggressive")
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNoiseProfile, noise.Name); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
egress.SetProfile(noise)
|
||||
}
|
||||
// A masked placeholder echoed back keeps the stored secret, like null.
|
||||
req.BraveKey, req.TavilyKey = keepIfMasked(req.BraveKey), keepIfMasked(req.TavilyKey)
|
||||
req.WebSearchProxy, req.GlobalProxy = keepIfMasked(req.WebSearchProxy), keepIfMasked(req.GlobalProxy)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { GaugeIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { NoiseProfileName, NoiseStatus } from "@/lib/types";
|
||||
|
||||
// Mirrors egress.Profiles in egress/egress.go; the jitter of one send is
|
||||
// min(Jitter/RPS, 250 ms).
|
||||
const profiles: { value: NoiseProfileName; label: string; limits: string }[] = [
|
||||
{ value: "off", label: "Off", limits: "Off: requests and their timing are unchanged, and nothing is counted." },
|
||||
{
|
||||
value: "quiet",
|
||||
label: "Quiet",
|
||||
limits:
|
||||
"2 req/s per host (burst 4), 1 in flight per host, 8 overall, jitter up to 250 ms, identical GETs of a task cached 60 s, up to 3 retries on 429/503.",
|
||||
},
|
||||
{
|
||||
value: "balanced",
|
||||
label: "Balanced",
|
||||
limits:
|
||||
"5 req/s per host (burst 10), 2 in flight per host, 16 overall, jitter up to 40 ms, identical GETs of a task cached 30 s, up to 3 retries on 429/503.",
|
||||
},
|
||||
{
|
||||
value: "aggressive",
|
||||
label: "Aggressive",
|
||||
limits:
|
||||
"20 req/s per host (burst 40), 4 in flight per host, 32 overall, jitter up to 5 ms, identical GETs of a task cached 10 s, up to 2 retries on 429/503.",
|
||||
},
|
||||
];
|
||||
|
||||
// NoiseProfileCard sets the target noise profile and shows the per-host
|
||||
// counters of the current minute.
|
||||
export function NoiseProfileCard() {
|
||||
const [status, setStatus] = React.useState<NoiseStatus | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const refresh = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.noiseStatus()
|
||||
.then(setStatus)
|
||||
.catch((e) => toast.error(`Failed to load the noise counters: ${(e as Error).message}`))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const save = (value: NoiseProfileName) => {
|
||||
setSaving(true);
|
||||
api
|
||||
.setSettings({ noise_profile: value })
|
||||
.then(() => {
|
||||
toast.success(`Noise profile set to ${value}`);
|
||||
refresh();
|
||||
})
|
||||
.catch((e) => toast.error(`Save failed: ${(e as Error).message}`))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const current = profiles.find((p) => p.value === status?.profile) ?? profiles[0];
|
||||
return (
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<GaugeIcon className="size-4" />
|
||||
Target noise
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Paces the requests ARTEX itself sends to targets (WebFetch, http custom tools, the enrich probe) and sets the
|
||||
low-and-slow guidance in agent prompts. Commands run through Bash are not paced. This setting takes
|
||||
precedence over ARTEX_NOISE_PROFILE and applies to the next request.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="noise-profile" className="text-sm font-normal text-muted-foreground">
|
||||
Profile
|
||||
</Label>
|
||||
<Select
|
||||
value={status?.profile ?? ""}
|
||||
disabled={!status || saving}
|
||||
onValueChange={(v) => save(v as NoiseProfileName)}
|
||||
>
|
||||
<SelectTrigger id="noise-profile" className="w-48 shrink-0">
|
||||
<SelectValue placeholder="Loading…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{current.limits}</p>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="font-medium text-sm">Hosts, current minute</span>
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
||||
<RefreshCwIcon data-icon="inline-start" className={loading ? "animate-spin" : undefined} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{status?.hosts.length ? (
|
||||
<Table aria-label="Noise counters per host">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead className="text-right">RPM</TableHead>
|
||||
<TableHead className="text-right">Peak RPM</TableHead>
|
||||
<TableHead className="text-right">In flight (peak)</TableHead>
|
||||
<TableHead className="text-right">Duplicates</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{status.hosts.map((h) => (
|
||||
<TableRow key={h.host}>
|
||||
<TableCell className="max-w-48 truncate font-mono text-xs" title={h.host}>
|
||||
{h.host}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{h.rpm}</TableCell>
|
||||
<TableCell className="text-right">{h.peak_rpm}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{h.inflight} ({h.peak_inflight})
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{h.duplicates_avoided}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status?.profile === "off" ? "Off counts no request." : "No paced request yet."}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { api } from "@/lib/api";
|
||||
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import type { Settings } from "@/lib/types";
|
||||
|
||||
import { NoiseProfileCard } from "./noise-profile-card";
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const [trafficCapture, setTrafficCapture] = React.useState(false);
|
||||
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
|
||||
@@ -292,6 +294,8 @@ export default function SystemSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<NoiseProfileCard />
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
|
||||
@@ -826,6 +826,7 @@ export const api = {
|
||||
// ---- app settings (runtime toggles) ----
|
||||
settings: () => get<Settings>(`/settings`),
|
||||
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
|
||||
noiseStatus: () => get<import("@/lib/types").NoiseStatus>(`/settings/noise`),
|
||||
// Run a real "test" search with the given (or saved) config to verify it works.
|
||||
testWebSearch: (patch: {
|
||||
web_search_backend?: string;
|
||||
|
||||
@@ -962,6 +962,23 @@ export interface Settings {
|
||||
// Render limits of IM messages; 0 = no truncation. The backend answers 400 outside the bounds.
|
||||
notify_summary_max_runes?: number; // summary characters per finding, 0-4000, default 120
|
||||
notify_assets_max_shown?: number; // asset names listed per finding, 0-50, default 3
|
||||
// Target noise profile; the stored setting wins over ARTEX_NOISE_PROFILE. Applies to the next request.
|
||||
noise_profile?: NoiseProfileName;
|
||||
}
|
||||
|
||||
export type NoiseProfileName = "off" | "quiet" | "balanced" | "aggressive";
|
||||
|
||||
// GET /api/settings/noise: the current profile and its per-host counters (current minute, no reset).
|
||||
export interface NoiseStatus {
|
||||
profile: NoiseProfileName;
|
||||
hosts: {
|
||||
host: string;
|
||||
rpm: number;
|
||||
peak_rpm: number;
|
||||
peak_inflight: number;
|
||||
inflight: number;
|
||||
duplicates_avoided: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ---- Vulnerability IM push ----
|
||||
|
||||
Reference in New Issue
Block a user