feat(agent): ship the Go agent canvas port — eino interrupt/resume + Redis check-pointing (#16035)

Replaces the Python agent canvas runtime with a Go implementation that
runs inside `cmd/server_main`.

The canvas compiles into an eino Workflow that pauses on wait-for-user
via native Interrupt/Resume (no sentinel flag) and resumes from a
Redis-backed CheckPointStore.

All 21 Python agent components and ~35 tools are ported with functional
parity.

Sandbox providers now read their JSON config from the admin-panel
system_settings table with env fallback.

234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate
added); drops the v2 DSL E2E step and the gap-analysis plan (both
redundant after the port ships).

## Type of change

- [x] Refactoring
- [x] New feature
- [x] Bug fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zhichang Yu
2026-06-17 13:24:03 +08:00
committed by GitHub
parent 2290bb0023
commit e45659868a
231 changed files with 33807 additions and 6114 deletions

View File

@@ -21,20 +21,20 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
// ErrCodeExecSandboxMissing is returned when the Phase 5 gRPC client to the
// Python sandbox (agent.sandbox.client.execute_code) is not yet wired. The
// Python sandbox itself is kept as-is per plan §2.11.4 (do NOT rewrite the
// sandbox); Phase 3 batch 1 only ships the tool shell, and Phase 5 adds
// the gRPC client.
// ErrCodeExecSandboxMissing is returned when no sandbox client is
// registered. The Python sandbox itself is kept as-is (the Go
// side never reimplemented the sandbox). When a client is
// registered via SetSandboxClient at boot, the tool dispatches
// the execution.
var ErrCodeExecSandboxMissing = errors.New(
"CodeExec sandbox gRPC client not yet implemented in Go — " +
"defer to Python Canvas",
"CodeExec sandbox client not registered — call SetSandboxClient at boot",
)
const codeExecToolName = "execute_code"
@@ -42,30 +42,44 @@ const codeExecToolName = "execute_code"
const codeExecToolDescription = "This tool has a sandbox that can execute code written in 'Python'/'Javascript'. " +
"It receives a piece of code and returns a JSON string."
// codeExecArgs is the JSON shape the model sends in. The Python tool
// accepts "lang" + "script" (see agent/tools/code_exec.py:303-310); we
// also accept "code" as a synonym since some DSLs and Phase 3 batch 1
// tests use that spelling.
// codeExecArgs is the JSON shape the model sends in. The Python
// tool accepts "lang" + "script"; we also accept "code" as a
// synonym since some DSLs and tests use that spelling.
type codeExecArgs struct {
Language string `json:"language,omitempty"`
Lang string `json:"lang,omitempty"`
Script string `json:"script,omitempty"`
Code string `json:"code,omitempty"`
Args map[string]any `json:"arguments,omitempty"`
// Timeout is the per-execution wall-clock budget in seconds. 0
// (the default) defers to the sandbox provider's own default
// (typically 30s). Mirrors Python's
// `code_exec.py:358 timeout_seconds = int(os.environ.get(...))`
// but the value flows per-call rather than per-process, which
// lets the model dial up/down for known-fast vs. known-slow
// scripts.
Timeout int `json:"timeout,omitempty"`
}
// codeExecResult is the JSON envelope returned to the model. The output
// shape mirrors the Python tool's `content` / `_ERROR` / `actual_type`
// fields so downstream nodes can pattern-match unchanged.
// fields so downstream nodes can pattern-match unchanged. Artifacts and
// Attachments are surfaced for the model and downstream component
// consumption (e.g. Message component's artifact markdown formatter).
type codeExecResult struct {
Content string `json:"content,omitempty"`
ActualType string `json:"actual_type,omitempty"`
Stub bool `json:"stub,omitempty"`
Error string `json:"_ERROR,omitempty"`
Content string `json:"content,omitempty"`
ActualType string `json:"actual_type,omitempty"`
Stub bool `json:"stub,omitempty"`
Error string `json:"_ERROR,omitempty"`
ExitCode int `json:"exit_code,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
Artifacts []map[string]any `json:"_ARTIFACTS,omitempty"`
Attachments []map[string]any `json:"attachments,omitempty"`
}
// CodeExecTool is the Phase 3 batch 1 shell for the CodeExec tool
// (plan §2.11.4 row 3, §5 Phase 3 第 1 批). It validates language +
// CodeExecTool is the for the CodeExec tool
// ( . It validates language +
// non-empty code and returns a structured "not-yet-wired" error.
type CodeExecTool struct{}
@@ -76,7 +90,7 @@ func NewCodeExecTool() *CodeExecTool {
}
// Info returns the tool's metadata for the chat model. The schema mirrors
// the Python CodeExecParam ToolMeta (plan §5 Phase 3, 字段对齐).
// the Python CodeExecParam ToolMeta (plan , 字段对齐).
func (c *CodeExecTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: codeExecToolName,
@@ -97,9 +111,8 @@ func (c *CodeExecTool) Info(_ context.Context) (*schema.ToolInfo, error) {
}, nil
}
// InvokableRun validates the inputs and returns the structured stub error.
// Phase 5 will replace the stub body with a gRPC call into the Python
// sandbox (per plan §2.11.4 "不重写沙箱" 决策).
// InvokableRun validates the inputs and dispatches to the
// registered sandbox client via SetSandboxClient.
func (c *CodeExecTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
var args codeExecArgs
if argumentsInJSON == "" {
@@ -124,9 +137,96 @@ func (c *CodeExecTool) InvokableRun(ctx context.Context, argumentsInJSON string,
return codeExecStubResult("code is required"), errors.New("code_exec: empty code")
}
// Phase 3 batch 1: gRPC client wires in Phase 5.
return codeExecStubResult(ErrCodeExecSandboxMissing.Error()),
ErrCodeExecSandboxMissing
// Dispatch to the registered SandboxClient. When the default
// stub is in place, the call surfaces
// ErrCodeExecSandboxMissing; once a real client is
// installed via SetSandboxClient at boot, the script runs.
client := GetSandboxClient()
resp, err := client.ExecuteCode(ctx, SandboxRequest{
Lang: lang,
Script: script,
Arguments: args.Args,
Timeout: args.Timeout,
})
if err != nil {
return codeExecStubResult(err.Error()), err
}
out, mErr := codeExecResultJSON(resp)
if mErr != nil {
return codeExecStubResult(mErr.Error()), mErr
}
return out, nil
}
// codeExecResultJSON serializes a SandboxResponse into the envelope
// the eino tool contract returns. Field mapping mirrors the Python
// tool's `code_exec.py:385-490` `_process_execution_result`:
//
// - Stdout / Stderr / ExitCode: stream directly through.
// - Returned → Content (the model's natural "what did main() give
// us back" field).
// - StructuredResult["actual_type"] → ActualType (Python
// `infer_actual_type` surface for downstream Message component).
// - Metadata["artifacts"] → Artifacts (the model AND the Message
// component's `_ARTIFACTS` collector both consume this; we
// surface it as `_ARTIFACTS` to match the Python envelope).
// - Metadata["attachments"] → Attachments (rendered into
// downstream Markdown by Message via the same path the Agent
// tool artifact markdown uses).
//
// Artifacts / Attachments with the wrong element type (anything
// other than map[string]any) are silently dropped with a log
// warning. This matches the Python tool's "skip on shape mismatch"
// semantics — better to lose one artifact than to abort the run.
func codeExecResultJSON(r *SandboxResponse) (string, error) {
if r == nil {
return codeExecStubResult("empty response"), nil
}
out := codeExecResult{
Content: r.Returned,
ExitCode: r.ExitCode,
Stdout: r.Stdout,
Stderr: r.Stderr,
}
if r.StructuredResult != nil {
if v, ok := r.StructuredResult["actual_type"].(string); ok {
out.ActualType = v
}
}
if r.Metadata != nil {
out.Artifacts = extractArtifactList(r.Metadata, "artifacts")
out.Attachments = extractArtifactList(r.Metadata, "attachments")
}
b, err := json.Marshal(out)
if err != nil {
return "", fmt.Errorf("code_exec: marshal result: %w", err)
}
return string(b), nil
}
// extractArtifactList pulls a list of dict-shaped entries out of
// Metadata[key]. Items that aren't map[string]any are dropped with
// a stderr log line so the operator can see the data loss without
// the run aborting.
func extractArtifactList(meta map[string]any, key string) []map[string]any {
raw, ok := meta[key]
if !ok {
return nil
}
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(arr))
for i, item := range arr {
m, ok := item.(map[string]any)
if !ok {
fmt.Fprintf(os.Stderr, "code_exec: %s[%d] is %T, expected map[string]any; dropping\n", key, i, item)
continue
}
out = append(out, m)
}
return out
}
func codeExecStubResult(msg string) string {