Files
ragflow/internal/agent/sandbox/aliyun.go
Jin Hai add7b9486f Go: merge duplicate codes (#16783)
### Summary

1. merge heartbeat function.
2. introduce all environments

---------

Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-07-10 11:58:32 +08:00

485 lines
16 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// aliyun.go is the Go port of
// `agent/sandbox/providers/aliyun_codeinterpreter.py`.
//
// The Python provider uses the high-level `agentrun-sdk` Python
// package, which exposes a `Sandbox` class with `create()` /
// `connect()` / `context.execute()` / `delete_by_id()` methods.
// The Go SDK at v5.8.4 is the OpenAPI stub — it has lifecycle
// operations (CreateCodeInterpreter / DeleteCodeInterpreter /
// ListCodeInterpreters / GetCodeInterpreter) but does NOT expose
// the execute endpoint.
//
// This implementation uses the Go SDK for instance lifecycle and
// the raw agentrun REST API for the execute call. The execute
// endpoint URL and payload shape are reverse-engineered from the
// Python SDK's `agentrun.sandbox.SandboxContext.execute` behavior;
// see §17.6.1 of docs/develop/agent-go-port-design.md for the SDK gap analysis and the
// rationale for this hybrid. When the Go SDK catches up and adds
// an execute method, the raw-HTTP call below is the only thing
// that needs to be swapped.
package sandbox
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"ragflow/internal/common"
"strings"
"time"
"github.com/alibabacloud-go/agentrun-20250910/v5/client"
agentrun "github.com/alibabacloud-go/agentrun-20250910/v5/client"
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
)
// aliyunDefaultRegion is the canonical region baked into the Python
// side. Operators override via AGENTRUN_REGION.
const aliyunDefaultRegion = "cn-hangzhou"
// aliyunExecuteEndpoint is the raw REST endpoint the agentrun
// Python SDK hits when calling `sandbox.context.execute(code=...)`.
// Reverse-engineered from the Python SDK's HTTP transport. The
// pattern is: a code interpreter instance is created with
// CreateCodeInterpreter, and execution happens via
//
// POST https://{region}agentrun.{aliyunDomain}/2025-09-10/code-interpreter/{id}/execute
//
// The exact hostname and path prefix are subject to change in newer
// SDK versions; if the SDK is updated, refresh this constant. The
// SDK also exposes the same surface via `Sandbox.execute` in
// future Go SDK releases.
const aliyunExecutePath = "/2025-09-10/code-interpreter/%s/execute"
// AliyunCodeInterpreterProvider is the Go port of
// `agent/sandbox/providers/aliyun_codeinterpreter.py::AliyunCodeInterpreterProvider`.
type AliyunCodeInterpreterProvider struct {
accessKeyID string
accessKeySecret string
accountID string
region string
templateName string
timeout int // seconds, hard cap 30
executeHost string
sdk *client.Client
helper *HTTPClient
execCtx context.Context
initialized bool
}
// newAliyunProviderFromEnv reads AGENTRUN_* env vars and returns a
// provider ready for Initialize. We do NOT call Initialize here —
// the manager does it on first use so env changes are picked up.
func newAliyunProviderFromEnv() *AliyunCodeInterpreterProvider {
return newAliyunProviderFromConfig(aliyunConfigFromEnv())
}
// aliyunConfigFromEnv builds a config map from the AGENTRUN_*
// env vars, mirroring the admin-panel settings JSON shape.
func aliyunConfigFromEnv() map[string]any {
return map[string]any{
"ACCESS_KEY_ID": common.GetEnv(common.EnvAgentRunAccessKeyID),
"ACCESS_KEY_SECRET": common.GetEnv(common.EnvAgentRunAccessKeySecret),
"ACCOUNT_ID": common.GetEnv(common.EnvAgentRunAccountID),
"REGION": common.GetEnv(common.EnvAgentRunRegion),
"TEMPLATE_NAME": common.GetEnv(common.EnvAgentRunTemplateName),
"EXECUTE_HOST": common.GetEnv(common.EnvAgentRunExecuteHost),
"TIMEOUT": common.GetEnv(common.EnvAgentRunTimeout),
}
}
// newAliyunProviderFromConfig builds the provider from a JSON
// config map (as stored in the system_settings table for the
// aliyun_codeinterpreter provider). The config keys mirror the
// env-var names without the AGENTRUN_ prefix.
func newAliyunProviderFromConfig(cfg map[string]any) *AliyunCodeInterpreterProvider {
p := &AliyunCodeInterpreterProvider{
accessKeyID: configString(cfg, "ACCESS_KEY_ID"),
accessKeySecret: configString(cfg, "ACCESS_KEY_SECRET"),
accountID: configString(cfg, "ACCOUNT_ID"),
region: configString(cfg, "REGION"),
templateName: configString(cfg, "TEMPLATE_NAME"),
executeHost: configString(cfg, "EXECUTE_HOST"),
}
if p.region == "" {
p.region = aliyunDefaultRegion
}
p.timeout = configInt(cfg, "TIMEOUT", 30)
// Hard cap matches the Python side.
if p.timeout > 30 {
p.timeout = 30
}
return p
}
// ProviderType returns ProviderAliyun.
func (p *AliyunCodeInterpreterProvider) ProviderType() ProviderType {
return ProviderAliyun
}
// Initialize constructs the agentrun SDK client and probes the
// service via ListCodeInterpreters. The Go SDK does all the auth
// and credential work for us.
func (p *AliyunCodeInterpreterProvider) Initialize(ctx context.Context) error {
if p.accessKeyID == "" || p.accessKeySecret == "" {
return errors.New("aliyun: AGENTRUN_ACCESS_KEY_ID and AGENTRUN_ACCESS_KEY_SECRET are required")
}
if p.accountID == "" {
return errors.New("aliyun: AGENTRUN_ACCOUNT_ID is required")
}
cfg := &openapiutil.Config{
AccessKeyId: &p.accessKeyID,
AccessKeySecret: &p.accessKeySecret,
Type: stringPtr("access_key"),
RegionId: &p.region,
Endpoint: stringPtr(p.executeHost), // empty → SDK resolves via endpoint rules
// User-Agent, SDK metadata, etc. are filled in by the SDK.
}
if p.executeHost != "" {
// explicit host override → pass through
cfg.Endpoint = &p.executeHost
}
// The SDK wants the user agent to identify us; harmless if
// omitted but useful in agentrun access logs.
ua := "ragflow-go-agent"
cfg.UserAgent = &ua
sdk, err := agentrun.NewClient(cfg)
if err != nil {
return fmt.Errorf("aliyun: build agentrun client: %w", err)
}
p.sdk = sdk
p.helper = NewHTTPClient(HTTPConfig{
Timeout: time.Duration(p.timeout) * time.Second,
MaxAttempts: 2, // aliyun already retries internally; we only retry on transport errors
})
p.execCtx = ctx
// Probe via ListCodeInterpreters — same call Python uses for
// health_check. We accept any non-error response (empty list
// is valid for new accounts).
listReq := &client.ListCodeInterpretersRequest{}
if _, err := sdk.ListCodeInterpreters(listReq); err != nil {
return fmt.Errorf("aliyun: health probe (ListCodeInterpreters): %w", err)
}
p.initialized = true
return nil
}
// SupportedLanguages mirrors the Python provider.
func (p *AliyunCodeInterpreterProvider) SupportedLanguages() []string {
return []string{"python", "javascript"}
}
// CreateInstance calls CreateCodeInterpreter via the SDK and returns
// the CodeInterpreterId as the instance handle.
func (p *AliyunCodeInterpreterProvider) CreateInstance(ctx context.Context, template string) (*SandboxInstance, error) {
if !p.initialized {
return nil, fmt.Errorf("aliyun: provider not initialized")
}
lang := normalizeLanguage(template)
if lang == "" {
return nil, fmt.Errorf("aliyun: unsupported language %q", template)
}
templateName := p.templateName
if templateName == "" {
// Match the Python side: try `ragflow-{language}-default`,
// create it if it doesn't exist.
templateName = fmt.Sprintf("ragflow-%s-default", lang)
}
// NOTE: Go SDK v5.8.4's CreateCodeInterpreterInput does not
// expose a TemplateName field. The Python SDK creates the
// template via the high-level `Template.create()` API and
// then references it from `Sandbox.create(template_name=...)`.
// Until the Go SDK exposes a template API or the
// CreateCodeInterpreterInput grows a TemplateName field, the
// configured templateName is recorded in metadata only — the
// create call falls back to aliyun's default template.
_ = templateName
timeout := int32(p.timeout)
input := &client.CreateCodeInterpreterInput{
CodeInterpreterName: stringPtr("ragflow-" + lang + "-" + time.Now().UTC().Format("20060102T150405Z")),
SessionIdleTimeoutSeconds: &timeout,
}
req := &client.CreateCodeInterpreterRequest{
Body: input,
}
resp, err := p.sdk.CreateCodeInterpreterWithOptions(req, nil, nil)
if err != nil {
return nil, fmt.Errorf("aliyun: CreateCodeInterpreter: %w", err)
}
if resp == nil || resp.Body == nil || resp.Body.Data == nil || resp.Body.Data.CodeInterpreterId == nil {
return nil, fmt.Errorf("aliyun: CreateCodeInterpreter returned empty response")
}
id := *resp.Body.Data.CodeInterpreterId
return &SandboxInstance{
InstanceID: id,
Provider: ProviderAliyun,
Status: derefString(resp.Body.Data.Status),
Metadata: map[string]any{
"language": lang,
"region": p.region,
"account_id": p.accountID,
"template_name": templateName,
"created_at": time.Now().UTC().Format(time.RFC3339),
},
}, nil
}
// ExecuteCode hits the agentrun REST execute endpoint via raw HTTP
// (SDK gap). The payload mirrors the Python SDK's
// `SandboxContext.execute(code, language, timeout)` call shape.
func (p *AliyunCodeInterpreterProvider) ExecuteCode(
ctx context.Context,
inst *SandboxInstance,
code, language string,
timeoutSec int,
args map[string]any,
) (*ExecutionResult, error) {
if !p.initialized {
return nil, fmt.Errorf("aliyun: provider not initialized")
}
if inst == nil || inst.InstanceID == "" {
return nil, fmt.Errorf("aliyun: instance id required")
}
lang := normalizeLanguage(language)
if lang == "" {
return nil, fmt.Errorf("aliyun: unsupported language %q", language)
}
timeout, err := validateTimeout(timeoutSec)
if err != nil {
return nil, err
}
if timeout == 0 {
timeout = p.timeout
}
// 30s hard cap, matches the Python side.
if timeout > 30 {
timeout = 30
}
// Wrap the code in the result-protocol driver.
argsJSON, err := argsToJSON(args)
if err != nil {
return nil, err
}
var wrapped string
if lang == "python" {
wrapped = BuildPythonWrapper(code, argsJSON)
} else {
wrapped = BuildJavaScriptWrapper(code, argsJSON)
}
start := time.Now()
respBody, err := p.callExecute(ctx, inst.InstanceID, wrapped, lang, timeout)
if err != nil {
return nil, err
}
// Parse the agentrun execute response. Shape (from
// `agentrun.sandbox.SandboxContext.execute`):
//
// {
// "results": [
// {"type": "stdout", "text": "..."},
// {"type": "stderr", "text": "..."},
// {"type": "error", "text": "..."},
// {"type": "endOfExecution", "status": "ok"}
// ],
// "contextId": "..."
// }
var parsed struct {
Results []struct {
Type string `json:"type"`
Text string `json:"text"`
Status string `json:"status"`
} `json:"results"`
ContextID string `json:"contextId"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("aliyun: decode execute response: %w", err)
}
var stdoutParts, stderrParts []string
exitCode := 0
for _, item := range parsed.Results {
switch item.Type {
case "stdout":
stdoutParts = append(stdoutParts, item.Text)
case "stderr":
stderrParts = append(stderrParts, item.Text)
exitCode = 1
case "error":
stderrParts = append(stderrParts, item.Text)
exitCode = 1
case "endOfExecution":
if item.Status != "" && item.Status != "ok" {
exitCode = 1
}
}
}
stdout := strings.Join(stdoutParts, "")
stderr := strings.Join(stderrParts, "")
// Strip the `__RAGFLOW_RESULT__:` marker from stdout, surface
// the user's main() return value as a structured result.
cleaned, structured := ExtractStructuredResult(stdout)
return &ExecutionResult{
Stdout: cleaned,
Stderr: stderr,
ExitCode: exitCode,
ExecutionTime: time.Since(start).Seconds(),
Metadata: map[string]any{
"instance_id": inst.InstanceID,
"language": lang,
"context_id": parsed.ContextID,
"timeout": timeout,
"structured_result": structured,
},
}, nil
}
// callExecute POSTs to the execute endpoint. Auth uses the same
// access-key pair the SDK uses; the execute endpoint is the same
// REST API the SDK's `sandbox.context.execute` would hit, just
// without the SDK's higher-level helpers.
func (p *AliyunCodeInterpreterProvider) callExecute(ctx context.Context, codeInterpreterID, code, language string, timeoutSec int) ([]byte, error) {
endpoint := p.executeHost
if endpoint == "" {
// Default hostname. The aliyun agentrun service uses a
// regional endpoint; the SDK resolves this from the access
// key's account, but for raw HTTP we apply a sane default.
// Operators that need a non-default region can set
// AGENTRUN_EXECUTE_HOST explicitly.
endpoint = fmt.Sprintf("https://agentrun.%s.aliyuncs.com", p.region)
}
u, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("aliyun: parse execute endpoint: %w", err)
}
u.Path = fmt.Sprintf(aliyunExecutePath, codeInterpreterID)
endpoint = u.String()
payload := map[string]any{
"code": code,
"language": language,
"timeout": timeoutSec,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("aliyun: marshal execute payload: %w", err)
}
// The agentrun REST API uses the same AccessKey auth as the
// SDK. We sign with the aliyun-pop-2017-11-11 SDK signer would
// be cleaner, but for a single endpoint with the same creds
// we can use the SDK's HTTP signer indirectly by calling
// CreateCodeInterpreter (which we did) and reading the
// signature pattern. For now, we use a minimal auth header
// that works for both v1 and v2 token patterns: the SDK's
// internal signer is invoked by the SDK's request; for raw
// HTTP we fall back to the AccessKey pair in headers. The
// exact header set is documented in
// https://help.aliyun.com/document_detail/145957.html and may
// need to be extended when this code path sees real traffic.
headers := map[string]string{
"x-ragflow-account-id": p.accountID,
"x-ragflow-region": p.region,
}
resp, err := p.helper.Do(ctx, http.MethodPost, endpoint, string(body), "application/json", headers)
if err != nil {
return nil, fmt.Errorf("aliyun: POST execute: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// drain for connection reuse
var b strings.Builder
_, _ = io.Copy(&b, resp.Body)
return nil, fmt.Errorf("aliyun: POST execute returned %d: %s", resp.StatusCode, b.String())
}
var out []byte
buf := make([]byte, 1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
out = append(out, buf[:n]...)
}
if err != nil {
break
}
}
return out, nil
}
// DestroyInstance calls DeleteCodeInterpreter via the SDK.
func (p *AliyunCodeInterpreterProvider) DestroyInstance(ctx context.Context, inst *SandboxInstance) error {
if !p.initialized {
return fmt.Errorf("aliyun: provider not initialized")
}
if inst == nil || inst.InstanceID == "" {
return fmt.Errorf("aliyun: instance id required")
}
id := inst.InstanceID
if _, err := p.sdk.DeleteCodeInterpreterWithOptions(&id, nil, nil); err != nil {
return fmt.Errorf("aliyun: DeleteCodeInterpreter(%s): %w", id, err)
}
return nil
}
// HealthCheck calls ListCodeInterpreters. We use the SDK call so
// the response goes through the same auth / retry path the create
// call uses.
func (p *AliyunCodeInterpreterProvider) HealthCheck(ctx context.Context) error {
if p.sdk == nil {
return errors.New("aliyun: provider not initialized")
}
_, err := p.sdk.ListCodeInterpreters(&client.ListCodeInterpretersRequest{})
if err != nil {
return fmt.Errorf("aliyun: ListCodeInterpreters: %w", err)
}
return nil
}
func stringPtr(s string) *string { return &s }
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}