mirror of
https://github.com/google/adk-go.git
synced 2026-10-28 16:44:38 +08:00
386 lines
13 KiB
Go
386 lines
13 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// 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 workflow
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"iter"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"google.golang.org/genai"
|
|
|
|
"google.golang.org/adk/v2/agent"
|
|
"google.golang.org/adk/v2/internal/typeutil"
|
|
"google.golang.org/adk/v2/session"
|
|
)
|
|
|
|
// EmittingFunctionFn is the streaming variant of a FunctionNode body:
|
|
// it may emit intermediate events before returning the terminal
|
|
// output. The shape mirrors DynamicFn (see dynamic_node.go) without a
|
|
// sub-scheduler.
|
|
//
|
|
// To pause for human input, emit a NewRequestInputEvent and return
|
|
// (zero, ErrNodeInterrupted); the engine parks the node and routes the
|
|
// resume payload per NodeConfig.RerunOnResume.
|
|
type EmittingFunctionFn[IN, OUT any] = func(
|
|
ctx agent.Context, input IN, emit func(*session.Event) error,
|
|
) (OUT, error)
|
|
|
|
// FunctionNode wraps a custom function.
|
|
//
|
|
// Exactly one of fn or emittingFn is set per node.
|
|
type FunctionNode struct {
|
|
BaseNode
|
|
fn func(ctx agent.Context, input any) (any, error)
|
|
emittingFn func(ctx agent.Context, input any, emit func(*session.Event) error) (any, error)
|
|
stateFieldNames []string
|
|
}
|
|
|
|
// StateFieldNames returns the list of state keys this node consumes.
|
|
func (n *FunctionNode) StateFieldNames() []string {
|
|
return n.stateFieldNames
|
|
}
|
|
|
|
// NewFunctionNode creates a new node wrapping a custom function using generics to automatically infer input and output types.
|
|
func NewFunctionNode[IN, OUT any](name string, fn func(ctx agent.Context, input IN) (OUT, error), cfg NodeConfig) *FunctionNode {
|
|
return newFunctionNodeWithResolvedSchemas[IN, OUT](name, fn, nil, nil, cfg)
|
|
}
|
|
|
|
// NewFunctionNodeWithSchema creates a new node wrapping a custom function using generics to automatically infer input and output types.
|
|
func NewFunctionNodeWithSchema[IN, OUT any](name string, fn func(ctx agent.Context, input IN) (OUT, error), inputSchema, outputSchema *jsonschema.Schema, cfg NodeConfig) (*FunctionNode, error) {
|
|
var ischema *jsonschema.Resolved
|
|
var err error
|
|
if inputSchema != nil {
|
|
ischema, err = inputSchema.Resolve(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving input schema: %w", err)
|
|
}
|
|
}
|
|
|
|
var oschema *jsonschema.Resolved
|
|
if outputSchema != nil {
|
|
oschema, err = outputSchema.Resolve(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving output schema: %w", err)
|
|
}
|
|
}
|
|
|
|
return newFunctionNodeWithResolvedSchemas[IN, OUT](name, fn, ischema, oschema, cfg), nil
|
|
}
|
|
|
|
// NewEmittingFunctionNode wraps fn as a streaming FunctionNode whose
|
|
// body may emit intermediate events (HITL prompts, progress, state
|
|
// deltas) before returning. See EmittingFunctionFn for the pause
|
|
// contract.
|
|
func NewEmittingFunctionNode[IN, OUT any](name string, fn EmittingFunctionFn[IN, OUT], cfg NodeConfig) *FunctionNode {
|
|
return newEmittingFunctionNodeWithResolvedSchemas[IN, OUT](name, fn, nil, nil, cfg)
|
|
}
|
|
|
|
// NewEmittingFunctionNodeWithSchema is the explicit-schema variant of
|
|
// NewEmittingFunctionNode. A nil schema is inferred from the
|
|
// corresponding generic type.
|
|
func NewEmittingFunctionNodeWithSchema[IN, OUT any](name string, fn EmittingFunctionFn[IN, OUT], inputSchema, outputSchema *jsonschema.Schema, cfg NodeConfig) (*FunctionNode, error) {
|
|
var ischema *jsonschema.Resolved
|
|
var err error
|
|
if inputSchema != nil {
|
|
ischema, err = inputSchema.Resolve(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving input schema: %w", err)
|
|
}
|
|
}
|
|
var oschema *jsonschema.Resolved
|
|
if outputSchema != nil {
|
|
oschema, err = outputSchema.Resolve(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving output schema: %w", err)
|
|
}
|
|
}
|
|
return newEmittingFunctionNodeWithResolvedSchemas[IN, OUT](name, fn, ischema, oschema, cfg), nil
|
|
}
|
|
|
|
// NewFunctionNodeFromState returns a FunctionNode whose user function
|
|
// receives a struct-of-parameters loaded from ctx.state. Field names
|
|
// default to the Go field name; override with a struct tag
|
|
// `state:"my_key"`. A field tagged `state:"node_input"` (or named
|
|
// "NodeInput") receives the raw predecessor output instead.
|
|
//
|
|
// Params must be a struct type.
|
|
func NewFunctionNodeFromState[Params, OUT any](
|
|
name string,
|
|
fn func(ctx agent.InvocationContext, p Params) (OUT, error),
|
|
cfg NodeConfig,
|
|
) (*FunctionNode, error) {
|
|
paramType := reflect.TypeOf(*new(Params))
|
|
if paramType == nil || paramType.Kind() != reflect.Struct {
|
|
return nil, fmt.Errorf("params must be a struct type")
|
|
}
|
|
|
|
var nodeInputField reflect.StructField
|
|
var hasNodeInput bool
|
|
var stateFieldNames []string
|
|
|
|
type fieldBinding struct {
|
|
fieldName string
|
|
stateKey string
|
|
}
|
|
var bindings []fieldBinding
|
|
|
|
for i := 0; i < paramType.NumField(); i++ {
|
|
field := paramType.Field(i)
|
|
if !field.IsExported() {
|
|
continue
|
|
}
|
|
|
|
tag := field.Tag.Get("state")
|
|
isNodeInput := false
|
|
stateKey := field.Name
|
|
|
|
if tag != "" {
|
|
stateKey = tag
|
|
if tag == "node_input" {
|
|
isNodeInput = true
|
|
}
|
|
} else if field.Name == "NodeInput" {
|
|
isNodeInput = true
|
|
}
|
|
|
|
if isNodeInput {
|
|
if hasNodeInput {
|
|
return nil, fmt.Errorf("multiple node_input fields found")
|
|
}
|
|
hasNodeInput = true
|
|
nodeInputField = field
|
|
} else {
|
|
bindings = append(bindings, fieldBinding{
|
|
fieldName: field.Name,
|
|
stateKey: stateKey,
|
|
})
|
|
stateFieldNames = append(stateFieldNames, stateKey)
|
|
}
|
|
}
|
|
|
|
var ischema *jsonschema.Resolved
|
|
if hasNodeInput {
|
|
rawSchema, err := jsonschema.ForType(nodeInputField.Type, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating schema for node_input: %w", err)
|
|
}
|
|
ischema, err = rawSchema.Resolve(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving input schema: %w", err)
|
|
}
|
|
}
|
|
|
|
oschemaRaw, err := jsonschema.For[OUT](nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating output schema: %w", err)
|
|
}
|
|
oschema, err := oschemaRaw.Resolve(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving output schema: %w", err)
|
|
}
|
|
|
|
wrappedFn := func(ctx agent.Context, input any) (any, error) {
|
|
sessionState := ctx.Session().State()
|
|
stateMap := make(map[string]any)
|
|
|
|
for _, b := range bindings {
|
|
val, err := sessionState.Get(b.stateKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("missing state value for required field %q (state key %q): %w", b.fieldName, b.stateKey, err)
|
|
}
|
|
stateMap[b.fieldName] = val
|
|
}
|
|
|
|
if hasNodeInput {
|
|
stateMap[nodeInputField.Name] = input
|
|
}
|
|
|
|
p, err := typeutil.ConvertToWithJSONSchema[any, Params](stateMap, nil)
|
|
if err != nil {
|
|
var typeErr *json.UnmarshalTypeError
|
|
if errors.As(err, &typeErr) {
|
|
parts := strings.Split(typeErr.Field, ".")
|
|
fieldName := parts[len(parts)-1]
|
|
if hasNodeInput && fieldName == nodeInputField.Name {
|
|
return nil, fmt.Errorf("new function node from state: invalid input type for node_input: %w", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to convert state value to field %q: %w", fieldName, err)
|
|
}
|
|
return nil, fmt.Errorf("failed to convert state to Params: %w", err)
|
|
}
|
|
|
|
output, err := fn(ctx, p)
|
|
if err != nil {
|
|
return output, err
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
return &FunctionNode{
|
|
BaseNode: NewBaseNodeWithSchemas(name, "", cfg, ischema, oschema),
|
|
fn: wrappedFn,
|
|
stateFieldNames: stateFieldNames,
|
|
}, nil
|
|
}
|
|
|
|
// newFunctionNodeWithResolvedSchemas is an internal constructor that consumes already resolved schemas.
|
|
func newFunctionNodeWithResolvedSchemas[IN, OUT any](name string, fn func(ctx agent.Context, input IN) (OUT, error), inputSchema, outputSchema *jsonschema.Resolved, cfg NodeConfig) *FunctionNode {
|
|
wrappedFn := func(ctx agent.Context, input any) (any, error) {
|
|
var output OUT
|
|
var err error
|
|
if input == nil {
|
|
var zero IN
|
|
output, err = fn(ctx, zero)
|
|
} else {
|
|
typedInput, ok := input.(IN)
|
|
if !ok {
|
|
// Fallback to the json-like input types that cannot be converted by the standard type assertion.
|
|
// E.g. tool nodes return map[string]any as input and user may define a struct as the target type.
|
|
// ConvertToWithJSONSchema also validates against inputSchema; the explicit Validate below covers
|
|
// the assertion-hit path so schema constraints (e.g. maxLength) are enforced regardless.
|
|
typedInput, err = typeutil.ConvertToWithJSONSchema[any, IN](input, inputSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new function node: invalid input type, expected %T: %w", new(IN), err)
|
|
}
|
|
} else if inputSchema != nil {
|
|
if err = typeutil.ValidateWithJSONSchema(typedInput, inputSchema); err != nil {
|
|
return nil, fmt.Errorf("function node %s: validation failed for input %T: %w", name, new(IN), err)
|
|
}
|
|
}
|
|
output, err = fn(ctx, typedInput)
|
|
}
|
|
|
|
if err != nil {
|
|
return output, err
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
return &FunctionNode{
|
|
BaseNode: NewBaseNodeWithSchemas(name, "", cfg, inputSchema, outputSchema),
|
|
fn: wrappedFn,
|
|
}
|
|
}
|
|
|
|
// newEmittingFunctionNodeWithResolvedSchemas is the internal
|
|
// constructor for the streaming variant.
|
|
func newEmittingFunctionNodeWithResolvedSchemas[IN, OUT any](name string, fn EmittingFunctionFn[IN, OUT], inputSchema, outputSchema *jsonschema.Resolved, cfg NodeConfig) *FunctionNode {
|
|
wrappedFn := func(ctx agent.Context, input any, emit func(*session.Event) error) (any, error) {
|
|
var typedInput IN
|
|
if input != nil {
|
|
t, ok := input.(IN)
|
|
if !ok {
|
|
// Fallback to the json-like input types that cannot be converted by the standard type assertion.
|
|
// E.g. tool nodes return map[string]any as input and user may define a struct as the target type.
|
|
// ConvertToWithJSONSchema also validates against inputSchema; the explicit Validate below covers
|
|
// the assertion-hit path so schema constraints (e.g. maxLength) are enforced regardless.
|
|
converted, err := typeutil.ConvertToWithJSONSchema[any, IN](input, inputSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new function node: invalid input type, expected %T: %w", new(IN), err)
|
|
}
|
|
t = converted
|
|
} else if inputSchema != nil {
|
|
if err := typeutil.ValidateWithJSONSchema(t, inputSchema); err != nil {
|
|
return nil, fmt.Errorf("function node %s: validation failed for input %T: %w", name, new(IN), err)
|
|
}
|
|
}
|
|
typedInput = t
|
|
}
|
|
|
|
output, err := fn(ctx, typedInput, emit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
return &FunctionNode{
|
|
BaseNode: NewBaseNodeWithSchemas(name, "", cfg, inputSchema, outputSchema),
|
|
emittingFn: wrappedFn,
|
|
}
|
|
}
|
|
|
|
// Run executes the function node with the given input and returns an iterator over events.
|
|
func (n *FunctionNode) Run(ctx agent.Context, input any) iter.Seq2[*session.Event, error] {
|
|
return func(yield func(*session.Event, error) bool) {
|
|
if n.emittingFn != nil {
|
|
n.runEmitting(ctx, input, yield)
|
|
return
|
|
}
|
|
output, err := n.fn(ctx, input)
|
|
if err != nil {
|
|
yield(nil, err)
|
|
return
|
|
}
|
|
|
|
// If the return type is already a *session.Event, yield it directly.
|
|
// This ensures that a custom FunctionNode can explicitly route its output
|
|
// (via event.Routes) to select conditional successors.
|
|
if ev, ok := output.(*session.Event); ok {
|
|
if ev.InvocationID == "" {
|
|
ev.InvocationID = ctx.InvocationID()
|
|
}
|
|
yield(ev, nil)
|
|
return
|
|
}
|
|
|
|
event := session.NewEvent(ctx, ctx.InvocationID())
|
|
if c, ok := output.(*genai.Content); ok {
|
|
event.Content = c
|
|
} else if c, ok := output.(genai.Content); ok {
|
|
event.Content = &c
|
|
} else {
|
|
event.Output = output
|
|
}
|
|
yield(event, nil)
|
|
}
|
|
}
|
|
|
|
// runEmitting executes the streaming variant, mirroring
|
|
// dynamicNode.Run: ErrNodeInterrupted swallows the result (the pause
|
|
// event was already emitted), any other error fails the node, and a
|
|
// nil output suppresses the terminal event.
|
|
func (n *FunctionNode) runEmitting(ctx agent.Context, input any, yield func(*session.Event, error) bool) {
|
|
emit, consumerGone := makeEmit(yield, ctx)
|
|
output, err := n.emittingFn(ctx, input, emit)
|
|
if consumerGone() {
|
|
return
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, ErrNodeInterrupted) {
|
|
return
|
|
}
|
|
yield(nil, err)
|
|
return
|
|
}
|
|
if output == nil {
|
|
return
|
|
}
|
|
event := session.NewEvent(ctx, ctx.InvocationID())
|
|
event.Output = output
|
|
if s, ok := output.(string); ok {
|
|
event.Content = &genai.Content{
|
|
Parts: []*genai.Part{{Text: s}},
|
|
}
|
|
}
|
|
yield(event, nil)
|
|
}
|