418 lines
12 KiB
Go
418 lines
12 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 provides core agent development kit utilities and types.
|
|
package adk
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/bytedance/sonic"
|
|
|
|
"github.com/cloudwego/eino/components/tool"
|
|
"github.com/cloudwego/eino/compose"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
var (
|
|
defaultAgentToolParam = schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
|
"request": {
|
|
Desc: "request to be processed",
|
|
Required: true,
|
|
Type: schema.String,
|
|
},
|
|
})
|
|
)
|
|
|
|
type AgentToolOptions struct {
|
|
fullChatHistoryAsInput bool
|
|
agentInputSchema *schema.ParamsOneOf
|
|
}
|
|
|
|
type AgentToolOption func(*AgentToolOptions)
|
|
|
|
// WithFullChatHistoryAsInput enables using the full chat history as input.
|
|
func WithFullChatHistoryAsInput() AgentToolOption {
|
|
return func(options *AgentToolOptions) {
|
|
options.fullChatHistoryAsInput = true
|
|
}
|
|
}
|
|
|
|
// WithAgentInputSchema sets a custom input schema for the agent tool.
|
|
func WithAgentInputSchema(schema *schema.ParamsOneOf) AgentToolOption {
|
|
return func(options *AgentToolOptions) {
|
|
options.agentInputSchema = schema
|
|
}
|
|
}
|
|
|
|
func withAgentToolEnableStreaming(enabled bool) tool.Option {
|
|
return tool.WrapImplSpecificOptFn(func(opt *agentToolOptions) {
|
|
opt.enableStreaming = enabled
|
|
})
|
|
}
|
|
|
|
// NewAgentTool creates a tool that wraps an agent for invocation.
|
|
//
|
|
// The agent must have a non-empty Name and Description, as they are used as
|
|
// the tool's name and description respectively. This is validated when Info()
|
|
// is called during tool setup.
|
|
//
|
|
// Event Streaming:
|
|
// When EmitInternalEvents is enabled in ToolsConfig, the agent tool will emit AgentEvent
|
|
// from the inner agent to the parent agent's AsyncGenerator, allowing real-time streaming
|
|
// of the inner agent's output to the end-user via Runner.
|
|
//
|
|
// Note that these forwarded events are NOT recorded in the parent agent's runSession.
|
|
// They are only emitted to the end-user and have no effect on the parent agent's state
|
|
// or checkpoint. The only exception is Interrupted action, which is propagated via
|
|
// CompositeInterrupt to enable proper interrupt/resume across agent boundaries.
|
|
//
|
|
// Action Scoping:
|
|
// Actions emitted by the inner agent are scoped to the agent tool boundary:
|
|
// - Interrupted: Propagated via CompositeInterrupt to allow proper interrupt/resume across boundaries
|
|
// - Exit, TransferToAgent, BreakLoop: Ignored outside the agent tool; these actions only affect
|
|
// the inner agent's execution and do not propagate to the parent agent
|
|
//
|
|
// This scoping ensures that nested agents cannot unexpectedly terminate or transfer control
|
|
// of their parent agent's execution flow.
|
|
func NewAgentTool(_ context.Context, agent Agent, options ...AgentToolOption) tool.BaseTool {
|
|
opts := &AgentToolOptions{}
|
|
for _, opt := range options {
|
|
opt(opts)
|
|
}
|
|
|
|
return &agentTool{
|
|
agent: agent,
|
|
fullChatHistoryAsInput: opts.fullChatHistoryAsInput,
|
|
inputSchema: opts.agentInputSchema,
|
|
}
|
|
}
|
|
|
|
// NewTypedAgentTool creates a new agent tool that wraps a TypedAgent as a tool.BaseTool.
|
|
func NewTypedAgentTool[M MessageType](_ context.Context, agent TypedAgent[M], options ...AgentToolOption) tool.BaseTool {
|
|
opts := &AgentToolOptions{}
|
|
for _, opt := range options {
|
|
opt(opts)
|
|
}
|
|
|
|
return &typedAgentTool[M]{
|
|
agent: agent,
|
|
fullChatHistoryAsInput: opts.fullChatHistoryAsInput,
|
|
inputSchema: opts.agentInputSchema,
|
|
}
|
|
}
|
|
|
|
type typedAgentTool[M MessageType] struct {
|
|
agent TypedAgent[M]
|
|
|
|
fullChatHistoryAsInput bool
|
|
inputSchema *schema.ParamsOneOf
|
|
}
|
|
|
|
type agentTool = typedAgentTool[*schema.Message]
|
|
|
|
type agentToolRequest struct {
|
|
Request string `json:"request"`
|
|
}
|
|
|
|
func (at *typedAgentTool[M]) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
|
name := at.agent.Name(ctx)
|
|
if name == "" {
|
|
return nil, errors.New("agent tool requires a non-empty Name")
|
|
}
|
|
desc := at.agent.Description(ctx)
|
|
if desc == "" {
|
|
return nil, errors.New("agent tool requires a non-empty Description")
|
|
}
|
|
param := at.inputSchema
|
|
if param == nil {
|
|
param = defaultAgentToolParam
|
|
}
|
|
|
|
return &schema.ToolInfo{
|
|
Name: name,
|
|
Desc: desc,
|
|
ParamsOneOf: param,
|
|
}, nil
|
|
}
|
|
|
|
func (at *typedAgentTool[M]) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
|
if cancelCtx := getCancelContext(ctx); cancelCtx != nil {
|
|
cancelCtx.markAgentToolDescendant()
|
|
}
|
|
|
|
gen, enableStreaming := getEmitGeneratorAndEnableStreaming[M](opts)
|
|
var ms *bridgeStore
|
|
var iter *AsyncIterator[*TypedAgentEvent[M]]
|
|
var err error
|
|
|
|
wasInterrupted, hasState, state := tool.GetInterruptState[[]byte](ctx)
|
|
if !wasInterrupted {
|
|
ms = newBridgeStore()
|
|
|
|
var input []M
|
|
if at.fullChatHistoryAsInput {
|
|
var zero M
|
|
if _, ok := any(zero).(*schema.Message); !ok {
|
|
// fullChatHistoryAsInput is only supported for *schema.Message agents and will not
|
|
// be extended to *schema.AgenticMessage. The chat history format and role semantics
|
|
// differ fundamentally between Message and AgenticMessage, and the history rewriting
|
|
// logic (role attribution, system message filtering, transfer messages) is specific
|
|
// to the Message model.
|
|
return "", fmt.Errorf("fullChatHistoryAsInput is only supported for *schema.Message agents")
|
|
}
|
|
msgInput, histErr := getReactChatHistory(ctx, at.agent.Name(ctx))
|
|
if histErr != nil {
|
|
return "", histErr
|
|
}
|
|
input = any(msgInput).([]M)
|
|
} else {
|
|
if at.inputSchema == nil {
|
|
req := &agentToolRequest{}
|
|
err = sonic.UnmarshalString(argumentsInJSON, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
argumentsInJSON = req.Request
|
|
}
|
|
input = newTypedUserMessages[M](argumentsInJSON)
|
|
}
|
|
|
|
runner := newTypedInvokableAgentToolRunner(at.agent, ms, enableStreaming)
|
|
iter = runner.Run(ctx, input,
|
|
append(extractAndDeriveAgentToolCancelCtx(ctx, at.agent.Name(ctx), opts), WithCheckPointID(bridgeCheckpointID), withSharedParentSession())...)
|
|
} else {
|
|
if !hasState {
|
|
return "", fmt.Errorf("agent tool '%s' interrupt has happened, but cannot find interrupt state", at.agent.Name(ctx))
|
|
}
|
|
|
|
ms = newResumeBridgeStore(bridgeCheckpointID, state)
|
|
|
|
agentOpts := extractAndDeriveAgentToolCancelCtx(ctx, at.agent.Name(ctx), opts)
|
|
agentOpts = append(agentOpts, withSharedParentSession())
|
|
|
|
runner := newTypedInvokableAgentToolRunner(at.agent, ms, enableStreaming)
|
|
iter, err = runner.Resume(ctx, bridgeCheckpointID, agentOpts...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
var lastEvent *TypedAgentEvent[M]
|
|
for {
|
|
event, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
|
|
if lastEvent != nil &&
|
|
lastEvent.Output != nil &&
|
|
lastEvent.Output.MessageOutput != nil &&
|
|
lastEvent.Output.MessageOutput.MessageStream != nil {
|
|
lastEvent.Output.MessageOutput.MessageStream.Close()
|
|
}
|
|
|
|
if event.Err != nil {
|
|
return "", event.Err
|
|
}
|
|
|
|
if gen != nil {
|
|
if event.Action == nil || event.Action.Interrupted == nil {
|
|
if parentRunCtx := getRunCtx(ctx); parentRunCtx != nil && len(parentRunCtx.RunPath) > 0 {
|
|
rp := make([]RunStep, 0, len(parentRunCtx.RunPath)+len(event.RunPath))
|
|
rp = append(rp, parentRunCtx.RunPath...)
|
|
rp = append(rp, event.RunPath...)
|
|
event.RunPath = rp
|
|
}
|
|
tmp := copyTypedAgentEvent(event)
|
|
gen.Send(event)
|
|
event = tmp
|
|
}
|
|
}
|
|
|
|
lastEvent = event
|
|
}
|
|
|
|
if lastEvent != nil && lastEvent.Action != nil && lastEvent.Action.Interrupted != nil {
|
|
data, existed, err_ := ms.Get(ctx, bridgeCheckpointID)
|
|
if err_ != nil {
|
|
return "", fmt.Errorf("failed to get interrupt info: %w", err_)
|
|
}
|
|
if !existed {
|
|
return "", fmt.Errorf("interrupt has happened, but cannot find interrupt info")
|
|
}
|
|
|
|
return "", tool.CompositeInterrupt(ctx, "agent tool interrupt", data,
|
|
lastEvent.Action.internalInterrupted)
|
|
}
|
|
|
|
if lastEvent == nil {
|
|
return "", errors.New("no event returned")
|
|
}
|
|
|
|
var ret string
|
|
if lastEvent.Output != nil {
|
|
if output := lastEvent.Output.MessageOutput; output != nil {
|
|
msg, err := output.GetMessage()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ret = extractTextContent(msg)
|
|
}
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// agentToolOptions is a wrapper structure used to convert AgentRunOption slices to tool.Option.
|
|
// It stores the agent name and corresponding run options for tool-specific processing.
|
|
type agentToolOptions struct {
|
|
agentName string
|
|
opts []AgentRunOption
|
|
enableStreaming bool
|
|
}
|
|
|
|
// typedAgentToolEventOptions carries the parent runner's event generator for a
|
|
// specific message type. This keeps forwarded internal events type-compatible
|
|
// with the parent event stream.
|
|
type typedAgentToolEventOptions[M MessageType] struct {
|
|
generator *AsyncGenerator[*TypedAgentEvent[M]]
|
|
}
|
|
|
|
func withAgentToolOptions(agentName string, opts []AgentRunOption) tool.Option {
|
|
return tool.WrapImplSpecificOptFn(func(opt *agentToolOptions) {
|
|
opt.agentName = agentName
|
|
opt.opts = opts
|
|
})
|
|
}
|
|
|
|
func withAgentToolEventGenerator(gen *AsyncGenerator[*AgentEvent]) tool.Option {
|
|
return withTypedAgentToolEventGenerator(gen)
|
|
}
|
|
|
|
func withTypedAgentToolEventGenerator[M MessageType](gen *AsyncGenerator[*TypedAgentEvent[M]]) tool.Option {
|
|
return tool.WrapImplSpecificOptFn(func(o *typedAgentToolEventOptions[M]) {
|
|
o.generator = gen
|
|
})
|
|
}
|
|
|
|
func getOptionsByAgentName(agentName string, opts []tool.Option) []AgentRunOption {
|
|
var ret []AgentRunOption
|
|
for _, opt := range opts {
|
|
o := tool.GetImplSpecificOptions[agentToolOptions](nil, opt)
|
|
if o != nil && o.agentName == agentName {
|
|
ret = append(ret, o.opts...)
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func extractAndDeriveAgentToolCancelCtx(ctx context.Context, agentName string, opts []tool.Option) []AgentRunOption {
|
|
agentOpts := getOptionsByAgentName(agentName, opts)
|
|
baseOpts := getCommonOptions(nil, agentOpts...)
|
|
parentCtx := baseOpts.cancelCtx
|
|
if parentCtx == nil {
|
|
parentCtx = getCancelContext(ctx)
|
|
}
|
|
if parentCtx != nil {
|
|
parentCtx.markAgentToolDescendant()
|
|
childCtx := parentCtx.deriveAgentToolCancelContext(ctx)
|
|
agentOpts = append(agentOpts, WrapImplSpecificOptFn(func(o *options) {
|
|
o.cancelCtx = childCtx
|
|
}))
|
|
}
|
|
return agentOpts
|
|
}
|
|
|
|
func getEmitGeneratorAndEnableStreaming[M MessageType](opts []tool.Option) (*AsyncGenerator[*TypedAgentEvent[M]], bool) {
|
|
o := tool.GetImplSpecificOptions[agentToolOptions](nil, opts...)
|
|
eventOptions := tool.GetImplSpecificOptions[typedAgentToolEventOptions[M]](nil, opts...)
|
|
if o == nil && eventOptions == nil {
|
|
return nil, false
|
|
}
|
|
|
|
var gen *AsyncGenerator[*TypedAgentEvent[M]]
|
|
if eventOptions != nil {
|
|
gen = eventOptions.generator
|
|
}
|
|
|
|
var enableStreaming bool
|
|
if o != nil {
|
|
enableStreaming = o.enableStreaming
|
|
}
|
|
|
|
return gen, enableStreaming
|
|
}
|
|
|
|
func getReactChatHistory(ctx context.Context, destAgentName string) ([]Message, error) {
|
|
var messages []Message
|
|
err := compose.ProcessState(ctx, func(ctx context.Context, st *State) error {
|
|
if len(st.Messages) == 0 {
|
|
return nil
|
|
}
|
|
messages = make([]Message, len(st.Messages)-1)
|
|
copy(messages, st.Messages[:len(st.Messages)-1])
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get chat history from state: %w", err)
|
|
}
|
|
|
|
var agentName string
|
|
if runCtx := getRunCtx(ctx); runCtx != nil && len(runCtx.RunPath) > 0 {
|
|
agentName = runCtx.RunPath[len(runCtx.RunPath)-1].agentName
|
|
}
|
|
|
|
a, t := GenTransferMessages(ctx, destAgentName)
|
|
messages = append(messages, a, t)
|
|
history := make([]Message, 0, len(messages))
|
|
for _, msg := range messages {
|
|
if msg.Role == schema.System {
|
|
continue
|
|
}
|
|
|
|
if msg.Role == schema.Assistant || msg.Role == schema.Tool {
|
|
msg = rewriteMessage(msg, agentName)
|
|
}
|
|
|
|
history = append(history, msg)
|
|
}
|
|
|
|
return history, nil
|
|
}
|
|
|
|
func newTypedUserMessages[M MessageType](text string) []M {
|
|
var zero M
|
|
switch any(zero).(type) {
|
|
case *schema.Message:
|
|
return any([]Message{schema.UserMessage(text)}).([]M)
|
|
case *schema.AgenticMessage:
|
|
return any([]*schema.AgenticMessage{schema.UserAgenticMessage(text)}).([]M)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newTypedInvokableAgentToolRunner[M MessageType](agent TypedAgent[M], store compose.CheckPointStore, enableStreaming bool) *TypedRunner[M] {
|
|
return &TypedRunner[M]{
|
|
a: agent,
|
|
enableStreaming: enableStreaming,
|
|
store: store,
|
|
}
|
|
}
|