Files
ttstd-infinite-canvas/backend/internal/agent/runtime/runtime.go
ddcat666 0ac61434df feat(admin): Agent 并发 - 后台可调同时对话上限并展示 Agent 服务状态
新增运行策略 agentMaxSessions(默认 30,范围 1-64),保存后同时下发到内嵌进程闸门与独立 yingce-agent 的 /v1/limit,降低上限不会中断已开始的对话。系统性能页展示 Agent 服务健康、进行中/排队数量,并可直接修改上限。
2026-09-30 03:37:54 +08:00

320 lines
9.8 KiB
Go

package runtime
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const requestLimit = 32 << 20
const lineLimit = 4 << 20
type Bridge struct {
Model func(context.Context, map[string]json.RawMessage) (any, error)
Tool func(context.Context, map[string]json.RawMessage) (any, error)
Event func(context.Context, map[string]json.RawMessage) (any, error)
}
type ProcessRequest struct {
BridgeURL string `json:"bridgeURL"`
BridgeToken string `json:"bridgeToken"`
SessionFile string `json:"sessionFile,omitempty"`
SessionJSONL string `json:"sessionJSONL,omitempty"`
SessionDir string `json:"sessionDir,omitempty"`
SessionID string `json:"sessionId,omitempty"`
UserID string `json:"userId,omitempty"`
CanvasID string `json:"canvasId,omitempty"`
RunID string `json:"runId,omitempty"`
CWD string `json:"cwd"`
AgentDir string `json:"agentDir"`
Prompt string `json:"prompt"`
SystemPrompt string `json:"systemPrompt"`
SkillPaths []string `json:"skillPaths,omitempty"`
EnabledSkills []map[string]any `json:"enabledSkills,omitempty"`
Profile map[string]any `json:"profile,omitempty"`
Memory map[string]any `json:"memory,omitempty"`
Canvas map[string]any `json:"canvas,omitempty"`
Features map[string]any `json:"features,omitempty"`
Tools []map[string]any `json:"tools"`
Compaction map[string]any `json:"compaction,omitempty"`
Permissions map[string]any `json:"permissions,omitempty"`
Model map[string]any `json:"model"`
}
// BridgeServer is the per-run callback endpoint. The Node runtime calls it for
// model, tool, and event steps. Authorization is the bearer token; binding can
// be loopback for the embedded process or the container network for yingce-agent.
type BridgeServer struct {
URL string
Token string
Close func()
}
func Run(ctx context.Context, request ProcessRequest, bridge Bridge) error {
release, err := acquireAgentProcess(ctx)
if err != nil {
return err
}
defer release()
if bridge.Model == nil || bridge.Tool == nil || bridge.Event == nil {
return errors.New("Agent bridge handlers are incomplete")
}
runtimeDir, err := RuntimeDir()
if err != nil {
return err
}
server, err := StartBridge("127.0.0.1:0", "127.0.0.1", bridge)
if err != nil {
return err
}
defer server.Close()
request.BridgeURL = server.URL
request.BridgeToken = server.Token
payload, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("encode Agent runtime request: %w", err)
}
cmd := exec.CommandContext(ctx, "node", "--max-old-space-size="+nodeMemoryMB(), filepath.Join(runtimeDir, "agent-runtime.mjs"))
cmd.Dir = runtimeDir
cmd.Stdin = strings.NewReader(string(payload))
// 环境变量白名单:只传 Node 运行所需的最少变量。
cmd.Env = []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + request.AgentDir,
"NODE_ENV=production",
"PI_OFFLINE=1",
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("open Agent runtime output: %w", err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("start Agent runtime: %w", err)
}
// StdoutPipe 的读取必须在 Wait 之前结束。Wait 会关闭管道,提前调用会把
// 正常退出误报为 "file already closed"。
lineErr := ReadOutput(stdout)
waitErr := cmd.Wait()
if lineErr != nil {
return lineErr
}
if waitErr != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("Agent runtime exited: %w", waitErr)
}
return nil
}
func StartBridge(listenAddr, advertiseHost string, bridge Bridge) (*BridgeServer, error) {
if bridge.Model == nil || bridge.Tool == nil || bridge.Event == nil {
return nil, errors.New("Agent bridge handlers are incomplete")
}
advertiseHost = strings.TrimSpace(advertiseHost)
if advertiseHost == "" || strings.ContainsAny(advertiseHost, `:/\\ `) {
return nil, errors.New("Agent bridge host must be a hostname or IP without a scheme or port")
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, fmt.Errorf("create Agent bridge credential: %w", err)
}
token := hex.EncodeToString(tokenBytes)
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
return nil, fmt.Errorf("listen for Agent bridge: %w", err)
}
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok || tcpAddr.Port == 0 {
_ = listener.Close()
return nil, errors.New("Agent bridge did not receive a TCP port")
}
mux := http.NewServeMux()
mux.HandleFunc("POST /model", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Model) })
mux.HandleFunc("POST /tool", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Tool) })
mux.HandleFunc("POST /event", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Event) })
server := &http.Server{Handler: mux, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 2 * time.Minute}
go func() { _ = server.Serve(listener) }()
return &BridgeServer{
URL: fmt.Sprintf("http://%s:%d", advertiseHost, tcpAddr.Port),
Token: token,
Close: func() { _ = server.Shutdown(context.Background()) },
}, nil
}
func ReadOutput(stdout io.Reader) error {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), lineLimit)
var lastError error
for scanner.Scan() {
var event struct {
Event string `json:"event"`
Message string `json:"message"`
}
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
lastError = fmt.Errorf("decode Agent runtime event: %w", err)
continue
}
if event.Event == "runtime_error" || event.Event == "bridge_error" {
lastError = errors.New(firstNonEmpty(event.Message, "Agent runtime failed"))
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read Agent runtime output: %w", err)
}
return lastError
}
func BridgeCall(w http.ResponseWriter, r *http.Request, expectedToken string, handler func(context.Context, map[string]json.RawMessage) (any, error)) {
if r.Header.Get("Authorization") != "Bearer "+expectedToken {
http.Error(w, `{"error":"unauthorized bridge"}`, http.StatusUnauthorized)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, requestLimit+1))
if err != nil || len(body) > requestLimit {
http.Error(w, `{"error":"bridge payload too large or unreadable"}`, http.StatusRequestEntityTooLarge)
return
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, `{"error":"invalid bridge payload"}`, http.StatusBadRequest)
return
}
result, err := handler(r.Context(), payload)
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
_ = json.NewEncoder(w).Encode(result)
}
func RuntimeDir() (string, error) {
candidates := []string{os.Getenv("CANVAS_PI_RUNTIME_DIR"), "/app/backend/agent-runtime/pi"}
if _, source, _, ok := runtime.Caller(0); ok {
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(source), "../../../agent-runtime/pi")))
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if info, err := os.Stat(filepath.Join(candidate, "agent-runtime.mjs")); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", errors.New("Agent runtime files are missing; set CANVAS_PI_RUNTIME_DIR")
}
const (
defaultAgentProcessLimit = 30
maxAgentProcessLimit = 64
)
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)
}
}
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 {
raw := strings.TrimSpace(os.Getenv("YINGCE_AGENT_NODE_MEMORY_MB"))
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 128 && parsed <= 8192 {
return strconv.Itoa(parsed)
}
return "512"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}