824 lines
28 KiB
Go
824 lines
28 KiB
Go
/*
|
|
* Copyright 2025 CloudWeGo Authors
|
|
*
|
|
* 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 adk
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/gob"
|
|
"errors"
|
|
"io"
|
|
|
|
"github.com/cloudwego/eino/adk/internal"
|
|
"github.com/cloudwego/eino/components/model"
|
|
"github.com/cloudwego/eino/compose"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
// ErrExceedMaxIterations indicates the agent reached the maximum iterations limit.
|
|
var ErrExceedMaxIterations = errors.New("exceeds max iterations")
|
|
|
|
type typedState[M MessageType] struct {
|
|
Messages []M
|
|
Extra map[string]any
|
|
|
|
// ToolInfos contains the tool definitions passed to the model via model.WithTools.
|
|
// Managed by the framework and modifiable by BeforeModelRewriteState handlers.
|
|
ToolInfos []*schema.ToolInfo
|
|
|
|
// DeferredToolInfos contains tool definitions for server-side deferred retrieval,
|
|
// passed to the model via model.WithDeferredTools. Nil when not in use.
|
|
DeferredToolInfos []*schema.ToolInfo
|
|
|
|
// Internal fields below - do not access directly.
|
|
// Kept exported for backward compatibility with existing checkpoints.
|
|
HasReturnDirectly bool
|
|
ReturnDirectlyToolCallID string
|
|
ToolGenActions map[string]*AgentAction
|
|
AgentName string
|
|
RemainingIterations int
|
|
ReturnDirectlyEvent *TypedAgentEvent[M]
|
|
RetryAttempt int
|
|
ToolMsgIDs map[string]map[string]string // toolName → callID → eino message ID
|
|
}
|
|
|
|
// State is the internal state of the ChatModelAgent.
|
|
//
|
|
// Deprecated: State is exported only for checkpoint backward compatibility.
|
|
// Do not use it directly.
|
|
type State = typedState[*schema.Message]
|
|
|
|
type agenticState = typedState[*schema.AgenticMessage]
|
|
|
|
const (
|
|
stateGobNameV07 = "_eino_adk_react_state"
|
|
|
|
// stateGobNameV080 is a v0.8.0-v0.8.3-only alias used after byte-patching
|
|
// raw checkpoint bytes in preprocessADKCheckpoint.
|
|
// It must stay the same byte length as stateGobNameV07 so the length-prefixed
|
|
// gob string in the stream remains valid.
|
|
stateGobNameV080 = "_eino_adk_state_v080_"
|
|
)
|
|
|
|
func init() {
|
|
// Checkpoint compatibility notes:
|
|
// - ADK/compose checkpoints are gob-encoded and may store state behind `any`, so gob relies on
|
|
// an on-wire type name to choose a local Go type.
|
|
// - Gob allows only one local Go type per name, and it treats "struct wire" and "GobEncoder wire"
|
|
// as incompatible even if the name matches.
|
|
//
|
|
// This file maintains 2 epochs of *State decoding:
|
|
// - v0.7.* and current: "_eino_adk_react_state" + struct wire → decode into *State directly.
|
|
// State's exported fields are a superset of v0.7, so gob handles missing fields gracefully.
|
|
// - v0.8.0-v0.8.3: "_eino_adk_react_state" + GobEncoder wire → byte-patched to stateGobNameV080,
|
|
// decode into stateV080 and migrate.
|
|
schema.RegisterName[*State](stateGobNameV07)
|
|
schema.RegisterName[*stateV080](stateGobNameV080)
|
|
|
|
schema.RegisterName[*typedState[*schema.AgenticMessage]]("_eino_adk_agentic_state")
|
|
schema.RegisterName[*TypedAgentEvent[*schema.AgenticMessage]]("_eino_adk_agentic_event")
|
|
|
|
// backward compatibility when decoding checkpoints created by v0.8.0 - v0.8.3
|
|
gob.Register(&AgentEvent{})
|
|
gob.Register(0)
|
|
|
|
schema.RegisterName[*TypedAgentInput[*schema.AgenticMessage]]("_eino_adk_agentic_agent_input")
|
|
schema.RegisterName[*typedAgentEventWrapper[*schema.AgenticMessage]]("_eino_adk_agentic_event_wrapper")
|
|
schema.RegisterName[*[]*typedAgentEventWrapper[*schema.AgenticMessage]]("_eino_adk_agentic_event_wrapper_slice")
|
|
schema.RegisterName[*reactInput]("_eino_adk_react_input")
|
|
schema.RegisterName[*agenticReactInput]("_eino_adk_agentic_react_input")
|
|
}
|
|
|
|
func (s *typedState[M]) getReturnDirectlyEvent() *TypedAgentEvent[M] {
|
|
return s.ReturnDirectlyEvent
|
|
}
|
|
|
|
func (s *typedState[M]) setReturnDirectlyEvent(event *TypedAgentEvent[M]) {
|
|
s.ReturnDirectlyEvent = event
|
|
}
|
|
|
|
func (s *typedState[M]) getRetryAttempt() int {
|
|
return s.RetryAttempt
|
|
}
|
|
|
|
func (s *typedState[M]) setRetryAttempt(attempt int) {
|
|
s.RetryAttempt = attempt
|
|
}
|
|
|
|
func (s *typedState[M]) getReturnDirectlyToolCallID() string {
|
|
return s.ReturnDirectlyToolCallID
|
|
}
|
|
|
|
func (s *typedState[M]) setReturnDirectlyToolCallID(id string) {
|
|
s.ReturnDirectlyToolCallID = id
|
|
s.HasReturnDirectly = id != ""
|
|
}
|
|
|
|
func (s *typedState[M]) getToolGenActions() map[string]*AgentAction {
|
|
return s.ToolGenActions
|
|
}
|
|
|
|
func (s *typedState[M]) setToolGenAction(key string, action *AgentAction) {
|
|
if s.ToolGenActions == nil {
|
|
s.ToolGenActions = make(map[string]*AgentAction)
|
|
}
|
|
s.ToolGenActions[key] = action
|
|
}
|
|
|
|
func (s *typedState[M]) popToolGenAction(key string) *AgentAction {
|
|
if s.ToolGenActions == nil {
|
|
return nil
|
|
}
|
|
action := s.ToolGenActions[key]
|
|
delete(s.ToolGenActions, key)
|
|
return action
|
|
}
|
|
|
|
func (s *typedState[M]) setToolMsgID(toolName, callID, msgID string) {
|
|
if s.ToolMsgIDs == nil {
|
|
s.ToolMsgIDs = make(map[string]map[string]string)
|
|
}
|
|
byCall := s.ToolMsgIDs[toolName]
|
|
if byCall == nil {
|
|
byCall = make(map[string]string)
|
|
s.ToolMsgIDs[toolName] = byCall
|
|
}
|
|
byCall[callID] = msgID
|
|
}
|
|
|
|
func (s *typedState[M]) popToolMsgID(toolName, callID string) string {
|
|
if s.ToolMsgIDs == nil {
|
|
return ""
|
|
}
|
|
byCall := s.ToolMsgIDs[toolName]
|
|
if byCall == nil {
|
|
return ""
|
|
}
|
|
id := byCall[callID]
|
|
delete(byCall, callID)
|
|
if len(byCall) == 0 {
|
|
delete(s.ToolMsgIDs, toolName)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func (s *typedState[M]) getRemainingIterations() int {
|
|
return s.RemainingIterations
|
|
}
|
|
|
|
func (s *typedState[M]) setRemainingIterations(iterations int) {
|
|
s.RemainingIterations = iterations
|
|
}
|
|
|
|
func (s *typedState[M]) decrementRemainingIterations() {
|
|
current := s.getRemainingIterations()
|
|
s.RemainingIterations = current - 1
|
|
}
|
|
|
|
// stateV080 handles the v0.8.0-v0.8.3 checkpoint format.
|
|
// In those versions, *State implemented GobEncoder and was registered under
|
|
// "_eino_adk_react_state". GobEncode serialized a stateSerialization struct
|
|
// into opaque bytes. This type's GobDecode reads that format.
|
|
// It is registered under "_eino_adk_state_v080_" — a same-length alias used
|
|
// only after byte-patching the checkpoint data in preprocessADKCheckpoint.
|
|
type stateV080 struct {
|
|
Messages []Message
|
|
HasReturnDirectly bool
|
|
ReturnDirectlyToolCallID string
|
|
ToolGenActions map[string]*AgentAction
|
|
AgentName string
|
|
RemainingIterations int
|
|
RetryAttempt int
|
|
ReturnDirectlyEvent *AgentEvent
|
|
Extra map[string]any
|
|
Internals map[string]any
|
|
}
|
|
|
|
// stateV080Serialization is the on-wire format that v0.8.0-v0.8.3 GobEncode produced.
|
|
// It is only used by stateV080.GobDecode to parse those legacy opaque bytes.
|
|
type stateV080Serialization stateV080
|
|
|
|
func (sc *stateV080) GobDecode(b []byte) error {
|
|
ss := &stateV080Serialization{}
|
|
if err := gob.NewDecoder(bytes.NewReader(b)).Decode(ss); err != nil {
|
|
return err
|
|
}
|
|
sc.Messages = ss.Messages
|
|
sc.HasReturnDirectly = ss.HasReturnDirectly
|
|
sc.ReturnDirectlyToolCallID = ss.ReturnDirectlyToolCallID
|
|
sc.ToolGenActions = ss.ToolGenActions
|
|
sc.AgentName = ss.AgentName
|
|
sc.RemainingIterations = ss.RemainingIterations
|
|
sc.Extra = ss.Extra
|
|
sc.Internals = ss.Internals
|
|
return nil
|
|
}
|
|
|
|
// stateV080ToState converts a legacy *stateV080 (v0.8.0-v0.8.3) to a current *State.
|
|
func stateV080ToState(sc *stateV080) *State {
|
|
s := &State{
|
|
Messages: sc.Messages,
|
|
HasReturnDirectly: sc.HasReturnDirectly,
|
|
ReturnDirectlyToolCallID: sc.ReturnDirectlyToolCallID,
|
|
ToolGenActions: sc.ToolGenActions,
|
|
AgentName: sc.AgentName,
|
|
RemainingIterations: sc.RemainingIterations,
|
|
Extra: sc.Extra,
|
|
}
|
|
if sc.ReturnDirectlyToolCallID != "" {
|
|
s.setReturnDirectlyToolCallID(sc.ReturnDirectlyToolCallID)
|
|
}
|
|
if sc.Internals != nil && s.RetryAttempt == 0 {
|
|
if v, ok := sc.Internals["_retryAttempt"].(int); ok {
|
|
s.RetryAttempt = v
|
|
}
|
|
}
|
|
if sc.Internals != nil && s.ReturnDirectlyEvent == nil {
|
|
if v, ok := sc.Internals["_returnDirectlyEvent"].(*AgentEvent); ok {
|
|
s.ReturnDirectlyEvent = v
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// SendToolGenAction attaches an AgentAction to the next tool event emitted for the
|
|
// current tool execution.
|
|
//
|
|
// Where/when to use:
|
|
// - Invoke within a tool's Run (Invokable/Streamable) implementation to include
|
|
// an action alongside that tool's output event.
|
|
// - The action is scoped by the current tool call context: if a ToolCallID is
|
|
// available, it is used as the key to support concurrent calls of the same
|
|
// tool with different parameters; otherwise, the provided toolName is used.
|
|
// - The stored action is ephemeral and will be popped and attached to the tool
|
|
// event when the tool finishes (including streaming completion).
|
|
//
|
|
// Limitation:
|
|
// - This function is intended for use within ChatModelAgent runs only. It relies
|
|
// on ChatModelAgent's internal State to store and pop actions, which is not
|
|
// available in other agent types.
|
|
func SendToolGenAction(ctx context.Context, toolName string, action *AgentAction) error {
|
|
key := toolName
|
|
toolCallID := compose.GetToolCallID(ctx)
|
|
if len(toolCallID) > 0 {
|
|
key = toolCallID
|
|
}
|
|
|
|
return compose.ProcessState(ctx, func(ctx context.Context, st *State) error {
|
|
st.setToolGenAction(key, action)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
type reactInput struct {
|
|
Messages []Message
|
|
}
|
|
|
|
type typedReactConfig[M MessageType] struct {
|
|
model model.BaseModel[M]
|
|
|
|
toolsConfig *compose.ToolsNodeConfig
|
|
modelWrapperConf *typedModelWrapperConfig[M]
|
|
|
|
toolsReturnDirectly map[string]bool
|
|
|
|
agentName string
|
|
|
|
maxIterations int
|
|
|
|
cancelCtx *cancelContext
|
|
|
|
// afterAgentFunc is called when the agent reaches a successful terminal state.
|
|
// It runs as a graph node, so compose.ProcessState is available.
|
|
afterAgentFunc func(ctx context.Context, msg M) (M, error)
|
|
}
|
|
|
|
type reactConfig = typedReactConfig[*schema.Message]
|
|
|
|
func genToolInfos(ctx context.Context, config *compose.ToolsNodeConfig) ([]*schema.ToolInfo, error) {
|
|
toolInfos := make([]*schema.ToolInfo, 0, len(config.Tools))
|
|
for _, t := range config.Tools {
|
|
tl, err := t.Info(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
toolInfos = append(toolInfos, tl)
|
|
}
|
|
|
|
return toolInfos, nil
|
|
}
|
|
|
|
type reactGraph = *compose.Graph[*reactInput, Message]
|
|
|
|
func getReturnDirectlyToolCallID(ctx context.Context) (string, bool) {
|
|
var toolCallID string
|
|
handler := func(_ context.Context, st *State) error {
|
|
toolCallID = st.getReturnDirectlyToolCallID()
|
|
return nil
|
|
}
|
|
|
|
_ = compose.ProcessState(ctx, handler)
|
|
|
|
return toolCallID, toolCallID != ""
|
|
}
|
|
|
|
func genReactState(config *reactConfig) func(ctx context.Context) *State {
|
|
return func(ctx context.Context) *State {
|
|
st := &State{
|
|
AgentName: config.agentName,
|
|
}
|
|
maxIter := 20
|
|
if config.maxIterations > 0 {
|
|
maxIter = config.maxIterations
|
|
}
|
|
st.setRemainingIterations(maxIter)
|
|
return st
|
|
}
|
|
}
|
|
|
|
func newReact(ctx context.Context, config *reactConfig) (reactGraph, error) {
|
|
const (
|
|
initNode_ = "Init"
|
|
chatModel_ = "ChatModel"
|
|
cancelCheckNode_ = "CancelCheck"
|
|
toolNode_ = "ToolNode"
|
|
afterToolCallsNode_ = "AfterToolCalls"
|
|
afterToolCallsCancelCheckNode_ = "AfterToolCallsCancelCheck"
|
|
afterAgentNode_ = "AfterAgent"
|
|
)
|
|
|
|
cancelCtx := config.cancelCtx
|
|
g := compose.NewGraph[*reactInput, Message](compose.WithGenLocalState(genReactState(config)))
|
|
_ = g.AddLambdaNode(initNode_, compose.InvokableLambda(func(ctx context.Context, input *reactInput) ([]Message, error) {
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *State) error {
|
|
st.Messages = append(st.Messages, input.Messages...)
|
|
return nil
|
|
})
|
|
return input.Messages, nil
|
|
}), compose.WithNodeName(initNode_))
|
|
|
|
var wrappedModel = config.model
|
|
if config.modelWrapperConf != nil {
|
|
wrappedModel = buildModelWrappers(config.model, config.modelWrapperConf)
|
|
}
|
|
|
|
toolsConfig := config.toolsConfig
|
|
|
|
toolsNode, err := compose.NewToolNode(ctx, toolsConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = g.AddChatModelNode(chatModel_, wrappedModel, compose.WithStatePreHandler(
|
|
func(ctx context.Context, input []Message, st *State) ([]Message, error) {
|
|
if st.getRemainingIterations() <= 0 {
|
|
return nil, ErrExceedMaxIterations
|
|
}
|
|
st.decrementRemainingIterations()
|
|
return input, nil
|
|
}), compose.WithNodeName(chatModel_))
|
|
|
|
// CancelAfterChatModel safe-point: on the tool-calls path, after the branch
|
|
// has confirmed that the model response contains tool calls (i.e. not a final
|
|
// answer). Skipped entirely when the model produces a final answer.
|
|
_ = g.AddLambdaNode(cancelCheckNode_, compose.InvokableLambda(func(ctx context.Context, msg Message) (Message, error) {
|
|
if cancelCtx != nil && cancelCtx.shouldCancel() {
|
|
if cancelCtx.getMode()&CancelAfterChatModel != 0 {
|
|
return nil, compose.StatefulInterrupt(ctx, "CancelAfterChatModel", msg)
|
|
}
|
|
}
|
|
wasInterrupted, hasState, state := compose.GetInterruptState[Message](ctx)
|
|
if wasInterrupted && hasState {
|
|
msg = state
|
|
}
|
|
return msg, nil
|
|
}), compose.WithNodeName(cancelCheckNode_))
|
|
|
|
toolPreHandle := func(ctx context.Context, _ Message, st *State) (Message, error) {
|
|
input := st.Messages[len(st.Messages)-1]
|
|
returnDirectly := config.toolsReturnDirectly
|
|
if execCtx := getTypedChatModelAgentExecCtx[*schema.Message](ctx); execCtx != nil && len(execCtx.runtimeReturnDirectly) > 0 {
|
|
returnDirectly = execCtx.runtimeReturnDirectly
|
|
}
|
|
if len(returnDirectly) > 0 {
|
|
for i := range input.ToolCalls {
|
|
toolName := input.ToolCalls[i].Function.Name
|
|
if _, ok := returnDirectly[toolName]; ok {
|
|
st.setReturnDirectlyToolCallID(input.ToolCalls[i].ID)
|
|
}
|
|
}
|
|
}
|
|
return input, nil
|
|
}
|
|
toolPostHandle := func(ctx context.Context, out *schema.StreamReader[[]*schema.Message], st *State) (*schema.StreamReader[[]*schema.Message], error) {
|
|
if event := st.getReturnDirectlyEvent(); event != nil {
|
|
getTypedChatModelAgentExecCtx[*schema.Message](ctx).send(event)
|
|
st.setReturnDirectlyEvent(nil)
|
|
}
|
|
return out, nil
|
|
}
|
|
_ = g.AddToolsNode(toolNode_, toolsNode,
|
|
compose.WithStatePreHandler(toolPreHandle),
|
|
compose.WithStreamStatePostHandler(toolPostHandle),
|
|
compose.WithNodeName(toolNode_))
|
|
|
|
// AfterToolCalls node: persists tool results to state and fires the after-tool-calls hook.
|
|
// The graph auto-materializes the ToolsNode stream into []Message before this node.
|
|
afterToolCalls := func(ctx context.Context, toolResults []Message) ([]Message, error) {
|
|
// Propagate tool message IDs from event sender to state messages.
|
|
// The event sender pre-generated IDs and stored them in state.ToolMsgIDs[toolName+callID].
|
|
// Here we pop them and set them on the compose-created tool result messages
|
|
// so that state messages share the same IDs as their corresponding event messages.
|
|
// If no stored ID is found (old checkpoint, custom event sender), generate a fresh one.
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *State) error {
|
|
for _, msg := range toolResults {
|
|
if id := st.popToolMsgID(msg.ToolName, msg.ToolCallID); id != "" {
|
|
msg.Extra = internal.SetMessageID(msg.Extra, id)
|
|
} else {
|
|
msg.Extra = internal.EnsureMessageID(msg.Extra)
|
|
}
|
|
st.Messages = append(st.Messages, msg)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
execCtx := getTypedChatModelAgentExecCtx[Message](ctx)
|
|
if execCtx != nil && execCtx.afterToolCallsHook != nil {
|
|
if err := execCtx.afterToolCallsHook(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return toolResults, nil
|
|
}
|
|
_ = g.AddLambdaNode(afterToolCallsNode_, compose.InvokableLambda(afterToolCalls),
|
|
compose.WithNodeName(afterToolCallsNode_))
|
|
|
|
// AfterToolCallsCancelCheck: CancelAfterToolCalls safe-point, separated from toolPostHandle.
|
|
afterToolCallsCancelCheck := func(ctx context.Context, toolResults []Message) ([]Message, error) {
|
|
if cancelCtx != nil && cancelCtx.shouldCancel() {
|
|
if cancelCtx.getMode()&CancelAfterToolCalls != 0 {
|
|
return nil, compose.Interrupt(ctx, "CancelAfterToolCalls")
|
|
}
|
|
}
|
|
return toolResults, nil
|
|
}
|
|
_ = g.AddLambdaNode(afterToolCallsCancelCheckNode_, compose.InvokableLambda(afterToolCallsCancelCheck),
|
|
compose.WithNodeName(afterToolCallsCancelCheckNode_))
|
|
|
|
_ = g.AddEdge(compose.START, initNode_)
|
|
_ = g.AddEdge(initNode_, chatModel_)
|
|
|
|
// Determine the terminal node: afterAgentNode_ if afterAgentFunc is set, otherwise compose.END.
|
|
terminalNode := compose.END
|
|
if config.afterAgentFunc != nil {
|
|
_ = g.AddLambdaNode(afterAgentNode_, compose.InvokableLambda(config.afterAgentFunc),
|
|
compose.WithNodeName(afterAgentNode_))
|
|
_ = g.AddEdge(afterAgentNode_, compose.END)
|
|
terminalNode = afterAgentNode_
|
|
}
|
|
|
|
toolCallCheck := func(ctx context.Context, sMsg MessageStream) (string, error) {
|
|
defer sMsg.Close()
|
|
for {
|
|
chunk, err_ := sMsg.Recv()
|
|
if err_ != nil {
|
|
if err_ == io.EOF {
|
|
return terminalNode, nil
|
|
}
|
|
|
|
return "", err_
|
|
}
|
|
|
|
if len(chunk.ToolCalls) > 0 {
|
|
return cancelCheckNode_, nil
|
|
}
|
|
}
|
|
}
|
|
branch := compose.NewStreamGraphBranch(toolCallCheck, map[string]bool{terminalNode: true, cancelCheckNode_: true})
|
|
_ = g.AddBranch(chatModel_, branch)
|
|
|
|
_ = g.AddEdge(cancelCheckNode_, toolNode_)
|
|
_ = g.AddEdge(toolNode_, afterToolCallsNode_)
|
|
_ = g.AddEdge(afterToolCallsNode_, afterToolCallsCancelCheckNode_)
|
|
|
|
if len(config.toolsReturnDirectly) > 0 {
|
|
const (
|
|
toolNodeToEndConverter = "ToolNodeToEndConverter"
|
|
)
|
|
|
|
cvt := func(ctx context.Context, toolResults []Message) (Message, error) {
|
|
id, _ := getReturnDirectlyToolCallID(ctx)
|
|
|
|
for _, msg := range toolResults {
|
|
if msg != nil && msg.ToolCallID == id {
|
|
return msg, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("return directly tool call result not found")
|
|
}
|
|
|
|
_ = g.AddLambdaNode(toolNodeToEndConverter, compose.InvokableLambda(cvt),
|
|
compose.WithNodeName(toolNodeToEndConverter))
|
|
_ = g.AddEdge(toolNodeToEndConverter, terminalNode)
|
|
|
|
checkReturnDirect := func(ctx context.Context, toolResults []Message) (string, error) {
|
|
_, ok := getReturnDirectlyToolCallID(ctx)
|
|
|
|
if ok {
|
|
return toolNodeToEndConverter, nil
|
|
}
|
|
|
|
return chatModel_, nil
|
|
}
|
|
|
|
returnDirectBranch := compose.NewGraphBranch(checkReturnDirect,
|
|
map[string]bool{toolNodeToEndConverter: true, chatModel_: true})
|
|
_ = g.AddBranch(afterToolCallsCancelCheckNode_, returnDirectBranch)
|
|
} else {
|
|
_ = g.AddEdge(afterToolCallsCancelCheckNode_, chatModel_)
|
|
}
|
|
|
|
return g, nil
|
|
}
|
|
|
|
type agenticReactInput struct {
|
|
Messages []*schema.AgenticMessage
|
|
}
|
|
|
|
type agenticReactConfig = typedReactConfig[*schema.AgenticMessage]
|
|
|
|
type agenticReactGraph = *compose.Graph[*agenticReactInput, *schema.AgenticMessage]
|
|
|
|
func getAgenticReturnDirectlyToolCallID(ctx context.Context) (string, bool) {
|
|
var toolCallID string
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *agenticState) error {
|
|
toolCallID = st.getReturnDirectlyToolCallID()
|
|
return nil
|
|
})
|
|
return toolCallID, toolCallID != ""
|
|
}
|
|
|
|
func genAgenticReactState(config *agenticReactConfig) func(ctx context.Context) *agenticState {
|
|
return func(ctx context.Context) *agenticState {
|
|
st := &agenticState{
|
|
AgentName: config.agentName,
|
|
}
|
|
maxIter := 20
|
|
if config.maxIterations > 0 {
|
|
maxIter = config.maxIterations
|
|
}
|
|
st.setRemainingIterations(maxIter)
|
|
return st
|
|
}
|
|
}
|
|
|
|
func agenticMessageHasToolCalls(msg *schema.AgenticMessage) bool {
|
|
if msg == nil {
|
|
return false
|
|
}
|
|
for _, block := range msg.ContentBlocks {
|
|
if block != nil && block.Type == schema.ContentBlockTypeFunctionToolCall && block.FunctionToolCall != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func newAgenticReact(ctx context.Context, config *agenticReactConfig) (agenticReactGraph, error) {
|
|
const (
|
|
initNode_ = "Init"
|
|
chatModel_ = "ChatModel"
|
|
cancelCheckNode_ = "CancelCheck"
|
|
toolNode_ = "ToolNode"
|
|
afterToolCallsNode_ = "AfterToolCalls"
|
|
afterToolCallsCancelCheckNode_ = "AfterToolCallsCancelCheck"
|
|
afterAgentNode_ = "AfterAgent"
|
|
)
|
|
|
|
cancelCtx := config.cancelCtx
|
|
g := compose.NewGraph[*agenticReactInput, *schema.AgenticMessage](
|
|
compose.WithGenLocalState(genAgenticReactState(config)))
|
|
_ = g.AddLambdaNode(initNode_, compose.InvokableLambda(func(ctx context.Context, input *agenticReactInput) ([]*schema.AgenticMessage, error) {
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *agenticState) error {
|
|
st.Messages = append(st.Messages, input.Messages...)
|
|
return nil
|
|
})
|
|
return input.Messages, nil
|
|
}), compose.WithNodeName(initNode_))
|
|
|
|
var wrappedModel = config.model
|
|
if config.modelWrapperConf != nil {
|
|
wrappedModel = buildModelWrappers(config.model, config.modelWrapperConf)
|
|
}
|
|
|
|
toolsNode, err := compose.NewAgenticToolsNode(ctx, config.toolsConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = g.AddAgenticModelNode(chatModel_, wrappedModel, compose.WithStatePreHandler(
|
|
func(ctx context.Context, input []*schema.AgenticMessage, st *agenticState) ([]*schema.AgenticMessage, error) {
|
|
if st.getRemainingIterations() <= 0 {
|
|
return nil, ErrExceedMaxIterations
|
|
}
|
|
st.decrementRemainingIterations()
|
|
return input, nil
|
|
}), compose.WithNodeName(chatModel_))
|
|
|
|
_ = g.AddLambdaNode(cancelCheckNode_, compose.InvokableLambda(func(ctx context.Context, msg *schema.AgenticMessage) (*schema.AgenticMessage, error) {
|
|
if cancelCtx != nil && cancelCtx.shouldCancel() {
|
|
if cancelCtx.getMode()&CancelAfterChatModel != 0 {
|
|
return nil, compose.StatefulInterrupt(ctx, "CancelAfterChatModel", msg)
|
|
}
|
|
}
|
|
wasInterrupted, hasState, state := compose.GetInterruptState[*schema.AgenticMessage](ctx)
|
|
if wasInterrupted && hasState {
|
|
msg = state
|
|
}
|
|
return msg, nil
|
|
}), compose.WithNodeName(cancelCheckNode_))
|
|
|
|
toolPreHandle := func(ctx context.Context, _ *schema.AgenticMessage, st *agenticState) (*schema.AgenticMessage, error) {
|
|
input := st.Messages[len(st.Messages)-1]
|
|
returnDirectly := config.toolsReturnDirectly
|
|
if execCtx := getTypedChatModelAgentExecCtx[*schema.AgenticMessage](ctx); execCtx != nil && len(execCtx.runtimeReturnDirectly) > 0 {
|
|
returnDirectly = execCtx.runtimeReturnDirectly
|
|
}
|
|
if len(returnDirectly) > 0 {
|
|
for _, block := range input.ContentBlocks {
|
|
if block == nil || block.Type != schema.ContentBlockTypeFunctionToolCall || block.FunctionToolCall == nil {
|
|
continue
|
|
}
|
|
if _, ok := returnDirectly[block.FunctionToolCall.Name]; ok {
|
|
st.setReturnDirectlyToolCallID(block.FunctionToolCall.CallID)
|
|
}
|
|
}
|
|
}
|
|
return input, nil
|
|
}
|
|
toolPostHandle := func(ctx context.Context, out *schema.StreamReader[[]*schema.AgenticMessage], st *agenticState) (*schema.StreamReader[[]*schema.AgenticMessage], error) {
|
|
if event := st.getReturnDirectlyEvent(); event != nil {
|
|
getTypedChatModelAgentExecCtx[*schema.AgenticMessage](ctx).send(event)
|
|
st.setReturnDirectlyEvent(nil)
|
|
}
|
|
return out, nil
|
|
}
|
|
_ = g.AddAgenticToolsNode(toolNode_, toolsNode,
|
|
compose.WithStatePreHandler(toolPreHandle),
|
|
compose.WithStreamStatePostHandler(toolPostHandle),
|
|
compose.WithNodeName(toolNode_))
|
|
|
|
afterToolCalls := func(ctx context.Context, toolResults []*schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
|
|
_ = compose.ProcessState(ctx, func(_ context.Context, st *agenticState) error {
|
|
for _, msg := range toolResults {
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
toolName, callID := extractToolIdentifiers(msg)
|
|
if id := st.popToolMsgID(toolName, callID); id != "" {
|
|
msg.Extra = internal.SetMessageID(msg.Extra, id)
|
|
} else {
|
|
msg.Extra = internal.EnsureMessageID(msg.Extra)
|
|
}
|
|
st.Messages = append(st.Messages, msg)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
execCtx := getTypedChatModelAgentExecCtx[*schema.AgenticMessage](ctx)
|
|
if execCtx != nil && execCtx.afterToolCallsHook != nil {
|
|
if err := execCtx.afterToolCallsHook(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return toolResults, nil
|
|
}
|
|
_ = g.AddLambdaNode(afterToolCallsNode_, compose.InvokableLambda(afterToolCalls),
|
|
compose.WithNodeName(afterToolCallsNode_))
|
|
|
|
afterToolCallsCancelCheck := func(ctx context.Context, toolResults []*schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
|
|
if cancelCtx != nil && cancelCtx.shouldCancel() {
|
|
if cancelCtx.getMode()&CancelAfterToolCalls != 0 {
|
|
return nil, compose.Interrupt(ctx, "CancelAfterToolCalls")
|
|
}
|
|
}
|
|
return toolResults, nil
|
|
}
|
|
_ = g.AddLambdaNode(afterToolCallsCancelCheckNode_, compose.InvokableLambda(afterToolCallsCancelCheck),
|
|
compose.WithNodeName(afterToolCallsCancelCheckNode_))
|
|
|
|
_ = g.AddEdge(compose.START, initNode_)
|
|
_ = g.AddEdge(initNode_, chatModel_)
|
|
|
|
// Determine the terminal node: afterAgentNode_ if afterAgentFunc is set, otherwise compose.END.
|
|
terminalNode := compose.END
|
|
if config.afterAgentFunc != nil {
|
|
_ = g.AddLambdaNode(afterAgentNode_, compose.InvokableLambda(config.afterAgentFunc),
|
|
compose.WithNodeName(afterAgentNode_))
|
|
_ = g.AddEdge(afterAgentNode_, compose.END)
|
|
terminalNode = afterAgentNode_
|
|
}
|
|
|
|
toolCallCheck := func(ctx context.Context, sMsg *schema.StreamReader[*schema.AgenticMessage]) (string, error) {
|
|
defer sMsg.Close()
|
|
for {
|
|
chunk, err_ := sMsg.Recv()
|
|
if err_ != nil {
|
|
if err_ == io.EOF {
|
|
return terminalNode, nil
|
|
}
|
|
return "", err_
|
|
}
|
|
if agenticMessageHasToolCalls(chunk) {
|
|
return cancelCheckNode_, nil
|
|
}
|
|
}
|
|
}
|
|
branch := compose.NewStreamGraphBranch(toolCallCheck, map[string]bool{terminalNode: true, cancelCheckNode_: true})
|
|
_ = g.AddBranch(chatModel_, branch)
|
|
|
|
_ = g.AddEdge(cancelCheckNode_, toolNode_)
|
|
_ = g.AddEdge(toolNode_, afterToolCallsNode_)
|
|
_ = g.AddEdge(afterToolCallsNode_, afterToolCallsCancelCheckNode_)
|
|
|
|
if len(config.toolsReturnDirectly) > 0 {
|
|
const (
|
|
toolNodeToEndConverter = "ToolNodeToEndConverter"
|
|
)
|
|
|
|
cvt := func(ctx context.Context, toolResults []*schema.AgenticMessage) (*schema.AgenticMessage, error) {
|
|
id, _ := getAgenticReturnDirectlyToolCallID(ctx)
|
|
for _, msg := range toolResults {
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
_, callID := extractToolIdentifiers(msg)
|
|
if callID == id {
|
|
return msg, nil
|
|
}
|
|
}
|
|
return nil, errors.New("return directly tool call result not found")
|
|
}
|
|
|
|
_ = g.AddLambdaNode(toolNodeToEndConverter, compose.InvokableLambda(cvt),
|
|
compose.WithNodeName(toolNodeToEndConverter))
|
|
_ = g.AddEdge(toolNodeToEndConverter, terminalNode)
|
|
|
|
checkReturnDirect := func(ctx context.Context, toolResults []*schema.AgenticMessage) (string, error) {
|
|
_, ok := getAgenticReturnDirectlyToolCallID(ctx)
|
|
if ok {
|
|
return toolNodeToEndConverter, nil
|
|
}
|
|
return chatModel_, nil
|
|
}
|
|
|
|
returnDirectBranch := compose.NewGraphBranch(checkReturnDirect,
|
|
map[string]bool{toolNodeToEndConverter: true, chatModel_: true})
|
|
_ = g.AddBranch(afterToolCallsCancelCheckNode_, returnDirectBranch)
|
|
} else {
|
|
_ = g.AddEdge(afterToolCallsCancelCheckNode_, chatModel_)
|
|
}
|
|
|
|
return g, nil
|
|
}
|
|
|
|
// extractToolIdentifiers extracts the tool name and call ID from an AgenticMessage
|
|
// that contains a tool result content block.
|
|
// Assumes one tool result per message, which is guaranteed by AgenticToolsNode
|
|
// (see compose.toolMessageToAgenticMessage).
|
|
func extractToolIdentifiers(msg *schema.AgenticMessage) (toolName, callID string) {
|
|
if msg == nil {
|
|
return "", ""
|
|
}
|
|
for _, block := range msg.ContentBlocks {
|
|
if block == nil {
|
|
continue
|
|
}
|
|
if block.Type == schema.ContentBlockTypeFunctionToolResult && block.FunctionToolResult != nil {
|
|
return block.FunctionToolResult.Name, block.FunctionToolResult.CallID
|
|
}
|
|
if block.Type == schema.ContentBlockTypeToolSearchResult && block.ToolSearchFunctionToolResult != nil {
|
|
return block.ToolSearchFunctionToolResult.Name, block.ToolSearchFunctionToolResult.CallID
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|