mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-29 04:08:12 +08:00
feat[Go]: port agent webhook trigger, agent file upload/download, component input-form + debug endpoints from Python (#16403)
port agent webhook trigger, agent file upload/download, component input-form + debug endpoints from Python - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
@@ -82,6 +82,16 @@ func (b *BeginComponent) Invoke(ctx context.Context, inputs map[string]any) (map
|
||||
state.Sys["user_id"] = uid
|
||||
}
|
||||
|
||||
// Webhook payload injection. The webhook HTTP handler sets
|
||||
// root["webhook_payload"] (see service/agent.go RunAgentWithWebhook)
|
||||
// which BuildWorkflow forwards into inputs. Surfacing it on
|
||||
// state.Sys lets downstream components read sys.webhook_payload the
|
||||
// same way they read sys.query / sys.user_id. The chat path never
|
||||
// sets this key, so existing tests stay green.
|
||||
if payload, ok := inputs["webhook_payload"].(map[string]any); ok && len(payload) > 0 {
|
||||
state.Sys["webhook_payload"] = payload
|
||||
}
|
||||
|
||||
// Passthrough: a shallow copy keeps the caller's map un-aliased.
|
||||
out := make(map[string]any, len(inputs))
|
||||
mapsCopy(out, inputs)
|
||||
@@ -106,18 +116,20 @@ func (b *BeginComponent) Stream(ctx context.Context, inputs map[string]any) (<-c
|
||||
// strings live on the struct / method above.
|
||||
func (b *BeginComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"query": "User query string (the chat input).",
|
||||
"user_id": "Optional user/tenant identifier.",
|
||||
"inputs": "Optional free-form inputs map; passthrough only.",
|
||||
"query": "User query string (the chat input).",
|
||||
"user_id": "Optional user/tenant identifier.",
|
||||
"webhook_payload": "Optional structured webhook request (set by the webhook HTTP handler; absent on chat flows).",
|
||||
"inputs": "Optional free-form inputs map; passthrough only.",
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs returns the same keys as Inputs (Begin is a passthrough).
|
||||
func (b *BeginComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"query": "Query string (passthrough).",
|
||||
"user_id": "User id, if provided (passthrough).",
|
||||
"inputs": "Raw inputs map (passthrough).",
|
||||
"query": "Query string (passthrough).",
|
||||
"user_id": "User id, if provided (passthrough).",
|
||||
"webhook_payload": "Webhook request payload, if provided (passthrough; also written to state.Sys[webhook_payload]).",
|
||||
"inputs": "Raw inputs map (passthrough).",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,3 +86,77 @@ func TestBegin_PassesThroughInputs(t *testing.T) {
|
||||
func withStateForTest(ctx context.Context, s *canvas.CanvasState) context.Context {
|
||||
return canvas.WithState(ctx, s)
|
||||
}
|
||||
|
||||
// TestBegin_InjectsWebhookPayload pins the contract added for the
|
||||
// webhook HTTP handler: when inputs["webhook_payload"] is present, Begin
|
||||
// must surface it on state.Sys["webhook_payload"] so downstream
|
||||
// components (Retrieval, Agent, etc.) can read sys.webhook_payload the
|
||||
// same way they read sys.query / sys.user_id.
|
||||
//
|
||||
// Mirrors python: agent/canvas.py (Begin component) reading
|
||||
// `webhook_payload` from inputs and writing to state.Sys in the webhook
|
||||
// branch.
|
||||
func TestBegin_InjectsWebhookPayload(t *testing.T) {
|
||||
c, _ := NewBeginComponent(nil)
|
||||
state := canvas.NewCanvasState("run-3", "task-3")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
payload := map[string]any{
|
||||
"query": map[string]any{"q": "hello"},
|
||||
"headers": map[string]any{"x-token": "abc"},
|
||||
"body": map[string]any{"k": "v"},
|
||||
}
|
||||
inputs := map[string]any{
|
||||
"query": "",
|
||||
"webhook_payload": payload,
|
||||
}
|
||||
out, err := c.Invoke(ctx, inputs)
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
got, ok := state.Sys["webhook_payload"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("state.Sys[webhook_payload] missing or wrong type: %T", state.Sys["webhook_payload"])
|
||||
}
|
||||
if !reflect.DeepEqual(got, payload) {
|
||||
t.Errorf("state.Sys[webhook_payload] mismatch:\n got %v\n want %v", got, payload)
|
||||
}
|
||||
// Passthrough preserved.
|
||||
if outPayload, _ := out["webhook_payload"].(map[string]any); !reflect.DeepEqual(outPayload, payload) {
|
||||
t.Errorf("outputs[webhook_payload] mismatch:\n got %v\n want %v", outPayload, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBegin_AbsentWebhookPayload confirms that the chat path (no
|
||||
// webhook_payload key in inputs) leaves state.Sys["webhook_payload"]
|
||||
// unset — adding the new branch must NOT pollute existing callers.
|
||||
func TestBegin_AbsentWebhookPayload(t *testing.T) {
|
||||
c, _ := NewBeginComponent(nil)
|
||||
state := canvas.NewCanvasState("run-4", "task-4")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
if _, err := c.Invoke(ctx, map[string]any{"query": "plain chat"}); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if _, ok := state.Sys["webhook_payload"]; ok {
|
||||
t.Errorf("state.Sys[webhook_payload] should not be set when inputs lack it; got %v", state.Sys["webhook_payload"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBegin_EmptyWebhookPayload confirms that an explicitly empty map
|
||||
// is treated as "not present" — matching the python `if payload:` guard.
|
||||
func TestBegin_EmptyWebhookPayload(t *testing.T) {
|
||||
c, _ := NewBeginComponent(nil)
|
||||
state := canvas.NewCanvasState("run-5", "task-5")
|
||||
ctx := canvas.WithState(context.Background(), state)
|
||||
|
||||
if _, err := c.Invoke(ctx, map[string]any{
|
||||
"query": "",
|
||||
"webhook_payload": map[string]any{},
|
||||
}); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if _, ok := state.Sys["webhook_payload"]; ok {
|
||||
t.Errorf("state.Sys[webhook_payload] should not be set for empty payload; got %v", state.Sys["webhook_payload"])
|
||||
}
|
||||
}
|
||||
|
||||
40
internal/agent/dsl/errors.go
Normal file
40
internal/agent/dsl/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// 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 dsl
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors returned by the extractors. Handlers use
|
||||
// errors.Is to map them to 102 (DataError) envelopes without
|
||||
// embedding the raw error text in the response.
|
||||
var (
|
||||
// ErrComponentNotFound is returned when the supplied
|
||||
// componentID does not exist in dsl["components"].
|
||||
ErrComponentNotFound = errors.New("dsl: component not found")
|
||||
|
||||
// ErrMissingInputForm is returned when the component exists
|
||||
// but has no `obj.input_form` dict. The python Canvas returns
|
||||
// None in this case; we surface 102 "component has no
|
||||
// input_form" instead.
|
||||
ErrMissingInputForm = errors.New("dsl: component has no input_form")
|
||||
|
||||
// ErrMalformedDSL is returned for structural problems — nil
|
||||
// dsl, missing components map, wrong types. Distinct from
|
||||
// ErrComponentNotFound so the handler can phrase the error
|
||||
// more clearly when the dsl is broken.
|
||||
ErrMalformedDSL = errors.New("dsl: malformed")
|
||||
)
|
||||
133
internal/agent/dsl/extract.go
Normal file
133
internal/agent/dsl/extract.go
Normal file
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// 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 dsl contains pure-function helpers for working with the agent
|
||||
// canvas DSL map structure (`map[string]any`). It is intentionally
|
||||
// runtime-free: no Canvas instantiation, no component factories, no
|
||||
// database access. The agent_component handlers use it to introspect
|
||||
// the DSL before deciding whether to wire up a runtime component.
|
||||
package dsl
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ExtractComponentInputForm returns the input-form schema dict stored at
|
||||
// `dsl["components"][componentID]["obj"]["input_form"]`.
|
||||
//
|
||||
// This is the Go equivalent of the python
|
||||
// `Canvas.get_component_input_form(component_id)` method
|
||||
// (api/agent/canvas.py:163) which reads the same path. The python
|
||||
// version walks the live Canvas object; we walk the raw DSL map
|
||||
// directly because the Go Canvas type does not expose an
|
||||
// introspection API (see plan §Gap C — there is no `GetComponent` on
|
||||
// the runtime Canvas type).
|
||||
//
|
||||
// Returns:
|
||||
// - the form-schema dict if present and well-typed
|
||||
// - ErrComponentNotFound if the componentID is missing from dsl
|
||||
// - ErrMissingInputForm if the component exists but has no input_form
|
||||
// - ErrMalformedDSL if the field is present but the wrong type
|
||||
//
|
||||
// Type errors (input_form is e.g. a list or a string) are NOT
|
||||
// collapsed into ErrMissingInputForm — they would mask a contract
|
||||
// violation in the DSL and let DebugComponent run against corrupt
|
||||
// data. CodeRabbit PR review #1 on PR #16403.
|
||||
func ExtractComponentInputForm(dsl map[string]any, componentID string) (map[string]any, error) {
|
||||
comp, err := navigateToComponent(dsl, componentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj, ok := comp["obj"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: component %q has no obj", ErrMalformedDSL, componentID)
|
||||
}
|
||||
rawForm, exists := obj["input_form"]
|
||||
if !exists || rawForm == nil {
|
||||
return nil, fmt.Errorf("%w: component %q has no input_form", ErrMissingInputForm, componentID)
|
||||
}
|
||||
form, ok := rawForm.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: component %q input_form is not a dict", ErrMalformedDSL, componentID)
|
||||
}
|
||||
return form, nil
|
||||
}
|
||||
|
||||
// ExtractComponentParams returns the params map stored at
|
||||
// `dsl["components"][componentID]["obj"]["params"]`. The debug handler
|
||||
// uses this to build the inputs map for the runtime Component.Invoke
|
||||
// call. Type errors collapse to ErrMalformedDSL (CodeRabbit PR
|
||||
// review #1).
|
||||
func ExtractComponentParams(dsl map[string]any, componentID string) (map[string]any, error) {
|
||||
comp, err := navigateToComponent(dsl, componentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj, ok := comp["obj"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: component %q has no obj", ErrMalformedDSL, componentID)
|
||||
}
|
||||
rawParams, exists := obj["params"]
|
||||
if !exists || rawParams == nil {
|
||||
return nil, nil
|
||||
}
|
||||
params, ok := rawParams.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: component %q params is not a dict", ErrMalformedDSL, componentID)
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// ExtractComponentName returns the component's class name (e.g.
|
||||
// "Begin", "LLM", "Retrieval") from `dsl["components"][componentID].
|
||||
// ["obj"]["component_name"]`. The runtime factory is keyed on this
|
||||
// name.
|
||||
func ExtractComponentName(dsl map[string]any, componentID string) (string, error) {
|
||||
comp, err := navigateToComponent(dsl, componentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
obj, ok := comp["obj"].(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: component %q has no obj", ErrMalformedDSL, componentID)
|
||||
}
|
||||
name, _ := obj["component_name"].(string)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("%w: component %q has no component_name", ErrMalformedDSL, componentID)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// navigateToComponent walks dsl["components"][componentID] and
|
||||
// returns the inner dict. Centralised so the three extractors above
|
||||
// share a single traversal path. (Renamed from extractComponent to
|
||||
// avoid colliding with the same-named helper in normalize.go.)
|
||||
func navigateToComponent(dsl map[string]any, componentID string) (map[string]any, error) {
|
||||
if dsl == nil {
|
||||
return nil, fmt.Errorf("%w: nil dsl", ErrMalformedDSL)
|
||||
}
|
||||
comps, ok := dsl["components"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: missing components map", ErrMalformedDSL)
|
||||
}
|
||||
comp, ok := comps[componentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q", ErrComponentNotFound, componentID)
|
||||
}
|
||||
cm, ok := comp.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q is not a dict", ErrMalformedDSL, componentID)
|
||||
}
|
||||
return cm, nil
|
||||
}
|
||||
149
internal/agent/dsl/extract_test.go
Normal file
149
internal/agent/dsl/extract_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
//
|
||||
// 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 dsl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// happyDSL returns a 2-component dsl suitable for the happy-path
|
||||
// extractors.
|
||||
func happyDSL() map[string]any {
|
||||
return map[string]any{
|
||||
"components": map[string]any{
|
||||
"begin": map[string]any{
|
||||
"obj": map[string]any{
|
||||
"component_name": "Begin",
|
||||
"params": map[string]any{
|
||||
"mode": "Manual",
|
||||
},
|
||||
"input_form": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"answer": map[string]any{
|
||||
"obj": map[string]any{
|
||||
"component_name": "Answer",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentInputForm_HappyPath(t *testing.T) {
|
||||
got, err := ExtractComponentInputForm(happyDSL(), "begin")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if q, ok := got["query"].(map[string]any); !ok || q["type"] != "string" {
|
||||
t.Errorf("query type = %v, want string", got["query"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentInputForm_NotFound(t *testing.T) {
|
||||
_, err := ExtractComponentInputForm(happyDSL(), "missing")
|
||||
if !errors.Is(err, ErrComponentNotFound) {
|
||||
t.Errorf("err = %v, want ErrComponentNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentInputForm_MissingObj(t *testing.T) {
|
||||
dsl := map[string]any{
|
||||
"components": map[string]any{
|
||||
"bare": map[string]any{}, // no obj
|
||||
},
|
||||
}
|
||||
_, err := ExtractComponentInputForm(dsl, "bare")
|
||||
if !errors.Is(err, ErrMalformedDSL) {
|
||||
t.Errorf("err = %v, want ErrMalformedDSL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentInputForm_MissingInputForm(t *testing.T) {
|
||||
// "answer" has obj but no input_form.
|
||||
_, err := ExtractComponentInputForm(happyDSL(), "answer")
|
||||
if !errors.Is(err, ErrMissingInputForm) {
|
||||
t.Errorf("err = %v, want ErrMissingInputForm", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentInputForm_NilDSL(t *testing.T) {
|
||||
_, err := ExtractComponentInputForm(nil, "anything")
|
||||
if !errors.Is(err, ErrMalformedDSL) {
|
||||
t.Errorf("err = %v, want ErrMalformedDSL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentParams_HappyPath(t *testing.T) {
|
||||
got, err := ExtractComponentParams(happyDSL(), "begin")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if got["mode"] != "Manual" {
|
||||
t.Errorf("mode = %v, want Manual", got["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentParams_NoParams(t *testing.T) {
|
||||
got, err := ExtractComponentParams(happyDSL(), "answer")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil (params is optional)", err)
|
||||
}
|
||||
if got != nil && len(got) != 0 {
|
||||
t.Errorf("params = %v, want empty/nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractComponentParams_WrongType pins that a present-but-
|
||||
// wrongly-typed params field is ErrMalformedDSL. CodeRabbit PR #1.
|
||||
func TestExtractComponentParams_WrongType(t *testing.T) {
|
||||
dsl := map[string]any{
|
||||
"components": map[string]any{
|
||||
"bad": map[string]any{
|
||||
"obj": map[string]any{
|
||||
"component_name": "Begin",
|
||||
"params": "this is a string, not a dict",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := ExtractComponentParams(dsl, "bad")
|
||||
if !errors.Is(err, ErrMalformedDSL) {
|
||||
t.Errorf("err = %v, want ErrMalformedDSL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentName_HappyPath(t *testing.T) {
|
||||
got, err := ExtractComponentName(happyDSL(), "begin")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if got != "Begin" {
|
||||
t.Errorf("name = %q, want Begin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractComponentName_NotFound(t *testing.T) {
|
||||
_, err := ExtractComponentName(happyDSL(), "missing")
|
||||
if !errors.Is(err, ErrComponentNotFound) {
|
||||
t.Errorf("err = %v, want ErrComponentNotFound", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user