feat(deploy): 画布 Agent - 独立 yingce-agent 容器并修复运行中断
生产会话不再因 stdout 管道提前关闭被误判失败。设置 YINGCE_AGENT_URL 后只走独立容器,失败不回退到内嵌进程。
This commit is contained in:
1 parent
3fb363aad2
commit
cb146d27a1
25 files changed
+821
-101
No files matched your search
@@ -0,0 +1,69 @@
|
||||
package yingceagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
||||
)
|
||||
|
||||
// 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
|
||||
// run the same step twice.
|
||||
func Run(ctx context.Context, endpoint, serviceToken, bridgeHost string, request agentruntime.ProcessRequest, bridge agentruntime.Bridge) 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")
|
||||
}
|
||||
server, err := agentruntime.StartBridge("0.0.0.0:0", bridgeHost, 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 remote Agent request: %w", err)
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/v1/runs", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create remote Agent request: %w", err)
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := remoteClient.Do(httpRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("call yingce-agent: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
detail, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
return fmt.Errorf("yingce-agent returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(detail)))
|
||||
}
|
||||
return agentruntime.ReadOutput(response.Body)
|
||||
}
|
||||
|
||||
var remoteClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package yingceagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
||||
)
|
||||
|
||||
func TestRemoteRunCallsBridgeAndRejectsBadToken(t *testing.T) {
|
||||
if _, err := exec.LookPath("node"); err != nil {
|
||||
t.Skip("node is not installed")
|
||||
}
|
||||
_, source, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate test file")
|
||||
}
|
||||
serverPath := filepath.Clean(filepath.Join(filepath.Dir(source), "../../../../yingce-agent/server.mjs"))
|
||||
runtimePath := filepath.Join(t.TempDir(), "agent-runtime.mjs")
|
||||
script := `
|
||||
let raw = "";
|
||||
for await (const chunk of process.stdin) raw += chunk;
|
||||
const request = JSON.parse(raw);
|
||||
const response = await fetch(new URL("/tool", request.bridgeURL), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer " + request.bridgeToken },
|
||||
body: JSON.stringify({ name: "lookup" }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
process.stdout.write(JSON.stringify({ event: "runtime_error", message: "bridge " + response.status }) + "\n");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({ event: "settled" }) + "\n");
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(runtimePath, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := strings.Repeat("ab", 16)
|
||||
cmd := exec.Command("node", serverPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"YINGCE_AGENT_TOKEN="+token,
|
||||
"YINGCE_AGENT_RUNTIME="+runtimePath,
|
||||
"PORT=0",
|
||||
"MAX_CONCURRENT_SESSIONS=2",
|
||||
)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
})
|
||||
reader := bufio.NewReader(stdout)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "YINGCE_AGENT_READY ") {
|
||||
t.Fatalf("server did not report its port: %q", line)
|
||||
}
|
||||
endpoint := "http://127.0.0.1:" + strings.TrimSpace(strings.TrimPrefix(line, "YINGCE_AGENT_READY "))
|
||||
|
||||
health, err := http.Get(endpoint + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer health.Body.Close()
|
||||
if health.StatusCode != http.StatusOK {
|
||||
t.Fatalf("health status %d", health.StatusCode)
|
||||
}
|
||||
|
||||
unauthorized, err := http.Post(endpoint+"/v1/runs", "application/json", strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unauthorized.Body.Close()
|
||||
if unauthorized.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("missing token status %d", unauthorized.StatusCode)
|
||||
}
|
||||
|
||||
called := false
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
err = Run(ctx, endpoint, token, "127.0.0.1", agentruntime.ProcessRequest{
|
||||
Model: map[string]any{"id": "m"},
|
||||
}, agentruntime.Bridge{
|
||||
Model: func(context.Context, map[string]json.RawMessage) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
},
|
||||
Tool: func(context.Context, map[string]json.RawMessage) (any, error) {
|
||||
called = true
|
||||
return map[string]any{"content": "ok"}, nil
|
||||
},
|
||||
Event: func(context.Context, map[string]json.RawMessage) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("remote runtime did not call the Go tool bridge")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user