mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-18 13:47:21 +08:00
Ported retrieval node, added Keenable web search tool - [x] New Feature (non-breaking change which adds functionality)
485 lines
16 KiB
Go
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"
|
|
"os"
|
|
"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": os.Getenv("AGENTRUN_ACCESS_KEY_ID"),
|
|
"ACCESS_KEY_SECRET": os.Getenv("AGENTRUN_ACCESS_KEY_SECRET"),
|
|
"ACCOUNT_ID": os.Getenv("AGENTRUN_ACCOUNT_ID"),
|
|
"REGION": os.Getenv("AGENTRUN_REGION"),
|
|
"TEMPLATE_NAME": os.Getenv("AGENTRUN_TEMPLATE_NAME"),
|
|
"EXECUTE_HOST": os.Getenv("AGENTRUN_EXECUTE_HOST"),
|
|
"TIMEOUT": os.Getenv("AGENTRUN_TIMEOUT"),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|