feat(admin): Agent 并发 - 后台可调同时对话上限并展示 Agent 服务状态
新增运行策略 agentMaxSessions(默认 30,范围 1-64),保存后同时下发到内嵌进程闸门与独立 yingce-agent 的 /v1/limit,降低上限不会中断已开始的对话。系统性能页展示 Agent 服务健康、进行中/排队数量,并可直接修改上限。
This commit is contained in:
@@ -222,27 +222,83 @@ func RuntimeDir() (string, error) {
|
||||
return "", errors.New("Agent runtime files are missing; set CANVAS_PI_RUNTIME_DIR")
|
||||
}
|
||||
|
||||
var (
|
||||
agentSlotsOnce sync.Once
|
||||
agentSlots chan struct{}
|
||||
const (
|
||||
defaultAgentProcessLimit = 30
|
||||
maxAgentProcessLimit = 64
|
||||
)
|
||||
|
||||
func acquireAgentProcess(ctx context.Context) (func(), error) {
|
||||
agentSlotsOnce.Do(func() {
|
||||
limit := 8
|
||||
if raw := strings.TrimSpace(os.Getenv("CANVAS_AGENT_MAX_PROCESSES")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 1000 {
|
||||
limit = parsed
|
||||
}
|
||||
type agentProcessGate struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
active int
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
var processGate = newAgentProcessGate()
|
||||
|
||||
func newAgentProcessGate() *agentProcessGate {
|
||||
limit := defaultAgentProcessLimit
|
||||
if raw := strings.TrimSpace(os.Getenv("CANVAS_AGENT_MAX_PROCESSES")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
limit = clampAgentProcessLimit(parsed)
|
||||
}
|
||||
agentSlots = make(chan struct{}, limit)
|
||||
})
|
||||
select {
|
||||
case agentSlots <- struct{}{}:
|
||||
return func() { <-agentSlots }, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return &agentProcessGate{limit: limit, wake: make(chan struct{})}
|
||||
}
|
||||
|
||||
func clampAgentProcessLimit(value int) int {
|
||||
if value < 1 {
|
||||
return defaultAgentProcessLimit
|
||||
}
|
||||
if value > maxAgentProcessLimit {
|
||||
return maxAgentProcessLimit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// SetProcessLimit changes how many embedded Agent processes may run at once.
|
||||
// Lowering the limit does not stop a process that has already started.
|
||||
func SetProcessLimit(value int) (active int, limit int) {
|
||||
processGate.mu.Lock()
|
||||
defer processGate.mu.Unlock()
|
||||
processGate.limit = clampAgentProcessLimit(value)
|
||||
close(processGate.wake)
|
||||
processGate.wake = make(chan struct{})
|
||||
return processGate.active, processGate.limit
|
||||
}
|
||||
|
||||
func ProcessUsage() (active int, limit int) {
|
||||
processGate.mu.Lock()
|
||||
defer processGate.mu.Unlock()
|
||||
return processGate.active, processGate.limit
|
||||
}
|
||||
|
||||
func acquireAgentProcess(ctx context.Context) (func(), error) {
|
||||
for {
|
||||
processGate.mu.Lock()
|
||||
if processGate.active < processGate.limit {
|
||||
processGate.active++
|
||||
processGate.mu.Unlock()
|
||||
return releaseAgentProcess, nil
|
||||
}
|
||||
wake := processGate.wake
|
||||
processGate.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-wake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func releaseAgentProcess() {
|
||||
processGate.mu.Lock()
|
||||
if processGate.active > 0 {
|
||||
processGate.active--
|
||||
}
|
||||
close(processGate.wake)
|
||||
processGate.wake = make(chan struct{})
|
||||
processGate.mu.Unlock()
|
||||
}
|
||||
|
||||
func nodeMemoryMB() string {
|
||||
|
||||
@@ -24,6 +24,25 @@ func fakeRuntime(t *testing.T, script string) {
|
||||
t.Setenv("CANVAS_PI_RUNTIME_DIR", dir)
|
||||
}
|
||||
|
||||
func TestSetProcessLimitDoesNotStopActiveWork(t *testing.T) {
|
||||
t.Cleanup(func() { SetProcessLimit(defaultAgentProcessLimit) })
|
||||
release, err := acquireAgentProcess(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, limit := SetProcessLimit(1)
|
||||
if active != 1 || limit != 1 {
|
||||
t.Fatalf("active=%d limit=%d, want active 1 and limit 1", active, limit)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := acquireAgentProcess(ctx); err == nil {
|
||||
t.Fatal("lowered limit still admitted another process")
|
||||
}
|
||||
release()
|
||||
SetProcessLimit(defaultAgentProcessLimit)
|
||||
}
|
||||
|
||||
func noopBridge() Bridge {
|
||||
handler := func(context.Context, map[string]json.RawMessage) (any, error) { return map[string]any{"ok": true}, nil }
|
||||
return Bridge{Model: handler, Tool: handler, Event: handler}
|
||||
|
||||
@@ -14,6 +14,68 @@ import (
|
||||
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
OK bool `json:"ok"`
|
||||
Active int `json:"active"`
|
||||
Limit int `json:"limit"`
|
||||
Queued int `json:"queued"`
|
||||
}
|
||||
|
||||
func Inspect(ctx context.Context, endpoint string) (Status, error) {
|
||||
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
|
||||
if endpoint == "" {
|
||||
return Status{}, fmt.Errorf("YINGCE_AGENT_URL is empty")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/healthz", nil)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
response, err := remoteClient.Do(request)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return Status{}, fmt.Errorf("yingce-agent health returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var status Status
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&status); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func SetLimit(ctx context.Context, endpoint, serviceToken string, limit int) error {
|
||||
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
|
||||
serviceToken = strings.TrimSpace(serviceToken)
|
||||
if endpoint == "" {
|
||||
return fmt.Errorf("YINGCE_AGENT_URL is empty")
|
||||
}
|
||||
if len(serviceToken) < 32 {
|
||||
return fmt.Errorf("YINGCE_AGENT_TOKEN must contain at least 32 characters")
|
||||
}
|
||||
payload, err := json.Marshal(map[string]int{"maxSessions": limit})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint+"/v1/limit", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := remoteClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
detail, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||
return fmt.Errorf("yingce-agent limit returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(detail)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes one Agent session on the independent yingce-agent service.
|
||||
// The Go process keeps the bridge, billing, and tool execution. A configured
|
||||
// remote endpoint never falls back to an embedded Node process: doing so can
|
||||
|
||||
@@ -116,4 +116,14 @@ if (!response.ok) {
|
||||
if !called {
|
||||
t.Fatal("remote runtime did not call the Go tool bridge")
|
||||
}
|
||||
if err := SetLimit(ctx, endpoint, token, 16); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := Inspect(ctx, endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Limit != 16 {
|
||||
t.Fatalf("limit = %d, want 16", status.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user