Files
ragflow/internal/agentic_rag/tool_run_javascript.go
T
Zhichang Yu 98b48a0857 Port agentic RAG to Go, expose it as a chat mode, and add per-dialog failover (#20503)
## Background

This branch started as a focused fix to agentic RAG regexp retrieval
semantics (`f80556585`) and grew into the full agentic RAG path. The
title no longer describes the contents, so it has been rewritten.

The PR now covers three largely independent lines of work:

### 1. The agentic RAG is reachable from the UI

`internal/agentic_rag` (the eino-ADK ReAct explorer) was already built
and wired, but only reachable by hand-crafting an `agent_mode` kwarg. It
is now the sixth option in the chat mode selector (`reasoning` level 5).

One subtlety worth stating plainly: **levels 1-4 and level 5 are not the
same agent.** Levels 1-4 go through `internal/rag/agentic-rag` (the
harness graph) with a depth chosen by `harnessModeForLevel`; level 5
switches engines outright to `internal/agentic_rag`. That is why level 5
must never reach `harnessModeForLevel` — its `level >= 4` case would
silently answer "ultra" for a level outside its domain.

### 2. Per-dialog failover chain

`agenticModelChain` resolved exactly one model and the caller then used
`chain[0]`, so a "chain" was never more than a single element. A dialog
can now configure an ordered list of fallback models in Chat Settings,
handed to `NewFailoverEinoChatModel` (sticky cursor plus a 30s
full-chain cooldown).

The list lives in the dialog's own `llm_setting.failover_llm_ids`, so no
new table is involved. A member that no longer resolves is skipped with
a warning rather than failing the turn.

Also removed: `tenant_model_group` / `tenant_model_group_mapping`, which
nothing ever read (the DAOs were constructed but never called, and no
frontend or Python code referenced the concept). Their removal takes an
explicit drop migration with it, plus the account-deletion cascade that
queried them.

### 3. A hung MiniMax stream (independent of the agentic work)

With any mode selected, a chat rendered its whole answer and then sat on
"thinking" forever. Root cause is `minimax.go:256`: MiniMax sends `data:
[DONE]` but leaves the HTTP connection open, and the code waited for the
scanner goroutine's EOF *after* `HandleStreamingResponse` had already
returned. That receive can only end when `streamCallTimeout` (20
minutes) expires.

Diagnosed by capturing a real SSE stream (the complete answer arrives,
the terminal `final: true` never does) and a goroutine dump (6 requests
parked in `chan receive`).

## Two review findings fixed on the way through

- **KB-scope authorization**: the agentic branch bypassed quote
resolution, and an empty KB scope made `buildBoolQueryFromCondition`
drop the `kb_id` filter — so a citation could resolve a chunk belonging
to a different KB in the same tenant. The agentic branch now requires a
non-empty scope and otherwise falls through to the regular path.
- **Stale documentation**: `agentic-rag-failover-groups.md` described
the "automatically include every tenant model" strategy that upstream
had already removed. It was rewritten for the per-dialog scope and then
dropped entirely, since the design now lives in the code it describes.

## Verification

- `bash build.sh --test`: `admin`, `dao`, `service`, `service/dataset`
and `entity/models` all pass
- The MiniMax fix was verified end-to-end against a live server: before,
the turn hung indefinitely; after, it completes in **1.9s** with `final:
true` present
- Frontend: 9 tests added; type-check and lint clean on the touched
files

## Not included

- **Attachment support in agentic mode.** Text attachments could be
appended safely, but images have no safe fix: the agent's toolset is
built around corpus retrieval and has no image input channel. Fixing
only the text path would leave the feature half-supported and harder to
diagnose than now. Planned as a follow-up PR, with the design synced
here first.
- Tool-calling is not enforced as a group constraint. `is_tools` is a
provider-declared flag rather than a measured capability (187 of 659
chat models do not declare it), so gating on it would reject working
configurations while admitting broken ones.
2026-10-02 23:00:16 +08:00

228 lines
9.1 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.
//
package agentic_rag
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/dop251/goja"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
// runJavascriptToolName is the tool the model calls to execute a sandbox-free
// ECMAScript 5.1 snippet.
const runJavascriptToolName = "run_javascript"
// runJavascriptToolDescription documents the strict ES5.1 sandbox contract:
// no module system, no external package, stdout is the output.
const runJavascriptToolDescription = "Execute a self-contained ECMAScript 5.1 (ES5.1) snippet and return its standard output.\n\n" +
"## When to use it (USE IS OPTIONAL - most questions need no code at all)\n" +
"Use it ONLY for computation over values you already hold: arithmetic on numbers, date/interval math, sorting or ranking lists, counting and aggregation, string/JSON parsing and transformation. The snippet must DERIVE its output from input values.\n" +
"Do NOT use it to think out loud. Reasoning, weighing evidence, comparing candidates, or deciding what to retrieve next belong in the `think` tool or in your own reply - never here. Anti-patterns that waste a turn: a snippet that is mostly `//` comments narrating your reasoning; a snippet whose only statement is `console.log(\"the answer is X\")` printing a constant you already knew; a snippet that echoes back a conclusion reached elsewhere. If you cannot name the INPUT VALUES the code transforms, you have no reason to call this tool.\n\n" +
"## Strict grammar & capability rules (VIOLATIONS ARE REJECTED)\n" +
"- **ECMAScript 5.1 ONLY.** The sandbox parser implements ES5.1, so any ES6+ syntax fails to compile (no let/const — use var; no arrow functions =>; no template literals; no destructuring; no classes; no default/rest params; no for-of; no generators; no spread; no Promise/async/await; no Proxy; no Symbol; no Map/Set — use plain objects/arrays).\n" +
"- **NO module system.** Do not use import, require, module.exports, exports, or define. There is NO Node.js/CommonJS runtime: external packages cannot be loaded. Only ES5.1 built-ins (Math, JSON, Array, Date, RegExp, String, Number, Object, etc.) are available.\n" +
"- **Output is stdout.** Write results with console.log(...). The captured stdout (joined lines) is returned verbatim as the tool output. The snippet's return value is ignored unless you also console.log it.\n\n" +
"## Input\n" +
"- code: the complete ES5.1 source to run.\n\n" +
"## Examples\n" +
"1. Sum an array:\n" +
" var nums = [1,2,3,4]; var s = 0; for (var i = 0; i < nums.length; i++) { s += nums[i]; } console.log(\"sum=\" + s);\n" +
"2. Parse JSON:\n" +
" var obj = JSON.parse('{\"a\":1,\"b\":2}'); console.log(obj.a + obj.b);"
// runJavascriptArgs is the JSON the model sends into InvokableRun.
type runJavascriptArgs struct {
Code string `json:"code"`
}
// Bounds protecting the service from hostile / runaway snippets.
const (
// runJavascriptMaxCodeBytes caps the source size accepted.
runJavascriptMaxCodeBytes = 64 << 10 // 64 KiB
// runJavascriptMaxStdoutBytes caps the captured stdout so a snippet cannot
// exhaust memory by printing an unbounded stream.
runJavascriptMaxStdoutBytes = 1 << 20 // 1 MiB
)
// RunJavascriptTool executes a strict ES5.1 snippet and returns its stdout.
type RunJavascriptTool struct{}
// NewRunJavascriptTool returns a RunJavascriptTool implementing eino's
// tool.InvokableTool.
func NewRunJavascriptTool() *RunJavascriptTool {
return &RunJavascriptTool{}
}
// Info returns the tool's metadata for the chat model.
func (t *RunJavascriptTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: runJavascriptToolName,
Desc: runJavascriptToolDescription,
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"code": {
Type: schema.String,
Required: true,
Desc: "The complete ECMAScript 5.1 source to execute. ES6+ syntax and any module/require/import usage are rejected.",
},
}),
}, nil
}
// es51ForbiddenPatterns are unambiguous module-system markers the sandbox cannot
// honor. Other ES6+ syntax is NOT listed here: goja only implements ES5.1, so
// real ES6 constructs fail at parse time anyway, while a substring scan of broad
// tokens (e.g. "=>", "let ", "of ", "class ", "...") would also reject those
// words appearing inside string literals or comments of valid ES5.1 code.
var es51ForbiddenPatterns = []string{
"import ", "import(", "export ", "require(", "require (",
"module.exports", "require ",
}
// InvokableRun validates the snippet against the ES5.1 contract, runs it in a
// fresh goja runtime, and returns the captured stdout. The snippet is bounded by
// the incoming context (deadline/cancellation triggers goja's interpreter
// interrupt, so e.g. `while(true){}` cannot hang the goroutine forever) and by
// code-size + stdout-size caps.
func (t *RunJavascriptTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
return guardedToolRun(ctx, runJavascriptToolName, t.invokableRun, argumentsInJSON)
}
func (t *RunJavascriptTool) invokableRun(ctx context.Context, argumentsInJSON string) (string, error) {
var args runJavascriptArgs
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("run_javascript: parse arguments: %w", err)
}
code := strings.TrimSpace(args.Code)
if code == "" {
return "", fmt.Errorf("run_javascript: code is required and must be a non-empty string")
}
if len(code) > runJavascriptMaxCodeBytes {
return "", fmt.Errorf("run_javascript: code too large (%d bytes > %d)", len(code), runJavascriptMaxCodeBytes)
}
if err := assertES51(code); err != nil {
return "", err
}
vm := goja.New()
// Capture console.log/stdout. goja has no console; the host injects it.
// The buffer is size-capped so an unbounded print cannot exhaust memory.
stdout := newBoundedBuffer(runJavascriptMaxStdoutBytes)
vm.Set("console", map[string]func(goja.FunctionCall) goja.Value{
"log": func(fc goja.FunctionCall) goja.Value {
for i, arg := range fc.Arguments {
if i > 0 {
stdout.writeByte(' ')
}
stdout.writeString(arg.String())
}
stdout.writeByte('\n')
return goja.Undefined()
},
})
// Interrupt the interpreter when the request context is done
// (deadline/cancellation). goja returns an InterruptedError at the next
// instruction boundary, so e.g. `while(true){}` ends when the run-level
// timeout cancels the agent's context — there is intentionally no separate
// per-tool wall-clock limit.
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
vm.Interrupt("run_javascript: interrupted: request context cancelled or timed out")
case <-done:
}
}()
if _, err := vm.RunString(code); err != nil {
// A syntax rejection means the snippet used constructs goja's ES5.1
// parser does not implement (arrow functions, classes, template
// literals, etc.); surface it clearly instead of a raw parse error.
// goja reports these as "SyntaxError" even when wrapped in a runtime
// exception.
if strings.Contains(err.Error(), "SyntaxError") {
return "", fmt.Errorf("run_javascript: ES6+ or unsupported syntax: %w", err)
}
return "", fmt.Errorf("run_javascript: execution error: %w", err)
}
return stdout.String(), nil
}
// boundedBuffer is a bytes.Buffer that silently stops growing once it reaches
// maxBytes, so a snippet's console output cannot exhaust host memory.
type boundedBuffer struct {
buf bytes.Buffer
max int
exceeded bool
}
func newBoundedBuffer(max int) *boundedBuffer {
return &boundedBuffer{max: max}
}
func (b *boundedBuffer) writeString(s string) {
if b.exceeded {
return
}
remaining := b.max - b.buf.Len()
if remaining <= 0 {
b.exceeded = true
return
}
if len(s) > remaining {
s = s[:remaining]
b.exceeded = true
}
b.buf.WriteString(s)
}
func (b *boundedBuffer) writeByte(c byte) {
if b.exceeded {
return
}
if b.buf.Len() >= b.max {
b.exceeded = true
return
}
b.buf.WriteByte(c)
}
func (b *boundedBuffer) String() string { return b.buf.String() }
// assertES51 rejects snippets that obviously use ES6+ syntax or a module
// system. goja itself only implements ES5.1, but this gives a clear, early
// error message instead of a confusing parse failure.
func assertES51(code string) error {
for _, p := range es51ForbiddenPatterns {
if strings.Contains(code, p) {
return fmt.Errorf("run_javascript: ES6+ or module syntax not allowed: found %q — only ECMAScript 5.1 is supported", p)
}
}
return nil
}