refactor(agent): Pi 会话运行时 - 接入原生 Agent 与 Skills 工具链

This commit is contained in:
ddcat666
2026-09-29 08:21:11 +08:00
parent f9b8c5a43e
commit 387d3562ad
69 changed files with 11460 additions and 207 deletions

View File

@@ -0,0 +1,191 @@
package runtime
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"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"`
}
func Run(ctx context.Context, request ProcessRequest, bridge Bridge) error {
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
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return fmt.Errorf("create Agent bridge credential: %w", err)
}
token := hex.EncodeToString(tokenBytes)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("listen for Agent bridge: %w", err)
}
defer listener.Close()
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}
serverDone := make(chan error, 1)
go func() { serverDone <- server.Serve(listener) }()
defer func() { _ = server.Shutdown(context.Background()) }()
request.BridgeURL = "http://" + listener.Addr().String()
request.BridgeToken = token
payload, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("encode Agent runtime request: %w", err)
}
cmd := exec.CommandContext(ctx, "node", filepath.Join(runtimeDir, "agent-runtime.mjs"))
cmd.Dir = runtimeDir
cmd.Stdin = strings.NewReader(string(payload))
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)
}
stdoutErr := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), lineLimit)
for scanner.Scan() {
var event struct {
Event string `json:"event"`
Message string `json:"message"`
}
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
stdoutErr <- fmt.Errorf("decode Agent runtime event: %w", err)
return
}
if event.Event == "runtime_error" || event.Event == "bridge_error" {
stdoutErr <- errors.New(firstNonEmpty(event.Message, "Agent runtime failed"))
return
}
}
if err := scanner.Err(); err != nil {
stdoutErr <- fmt.Errorf("read Agent runtime output: %w", err)
return
}
stdoutErr <- nil
}()
waitErr := cmd.Wait()
lineErr := <-stdoutErr
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 BridgeCall(w http.ResponseWriter, r *http.Request, expectedToken string, handler func(context.Context, map[string]json.RawMessage) (any, error)) {
if r.RemoteAddr == "" || !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
http.Error(w, `{"error":"loopback bridge only"}`, http.StatusForbidden)
return
}
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")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}