diff --git a/adk/chatmodel.go b/adk/chatmodel.go index 183f2de2a..ce6e945d4 100644 --- a/adk/chatmodel.go +++ b/adk/chatmodel.go @@ -296,6 +296,15 @@ type ToolsConfig struct { // The map keys are tool names indicate whether the tool should trigger immediate return. ReturnDirectly map[string]bool + // AllowRuntimeReturnDirectly lets a tool decide from its own execution result + // whether the agent should stop and return that result, by calling + // SetReturnDirectly during the tool call. + // + // It is only needed when no tool is listed in ReturnDirectly: the direct-return + // path is built into the agent when either of the two is set, so agents that use + // neither keep their original graph and pay nothing for this feature. + AllowRuntimeReturnDirectly bool + // EmitInternalEvents indicates whether internal events from agentTool should be emitted // to the parent agent's AsyncGenerator, allowing real-time streaming of nested agent output // to the end-user via Runner. @@ -1405,9 +1414,10 @@ func (a *TypedChatModelAgent[M]) buildMessageReActRunFunc(_ context.Context, bc failoverConfig: any(a.modelFailoverConfig).(*ModelFailoverConfig[*schema.Message]), toolInfos: bc.toolInfos, }, - toolsReturnDirectly: bc.returnDirectly, - agentName: a.name, - maxIterations: a.maxIterations, + toolsReturnDirectly: bc.returnDirectly, + allowRuntimeReturnDirectly: a.toolsConfig.AllowRuntimeReturnDirectly, + agentName: a.name, + maxIterations: a.maxIterations, } return func(ctx context.Context, p *typedRunParams[M]) { @@ -1541,9 +1551,10 @@ func (a *TypedChatModelAgent[M]) buildAgenticReActRunFunc(_ context.Context, bc failoverConfig: any(a.modelFailoverConfig).(*ModelFailoverConfig[*schema.AgenticMessage]), toolInfos: bc.toolInfos, }, - toolsReturnDirectly: bc.returnDirectly, - agentName: a.name, - maxIterations: a.maxIterations, + toolsReturnDirectly: bc.returnDirectly, + allowRuntimeReturnDirectly: a.toolsConfig.AllowRuntimeReturnDirectly, + agentName: a.name, + maxIterations: a.maxIterations, } return func(ctx context.Context, p *typedRunParams[M]) { diff --git a/adk/react.go b/adk/react.go index fdba8dd34..8d22430dc 100644 --- a/adk/react.go +++ b/adk/react.go @@ -33,6 +33,13 @@ import ( // ErrExceedMaxIterations indicates the agent reached the maximum iterations limit. var ErrExceedMaxIterations = errors.New("exceeds max iterations") +// errReturnDirectlyNotAllowed is returned when a tool asks for a direct return +// but the agent's graph has no direct-return path, so honoring the request is +// impossible. Reporting it beats silently continuing the loop. +var errReturnDirectlyNotAllowed = errors.New("requires the agent to allow returning directly: " + + "set ToolsConfig.AllowRuntimeReturnDirectly, or list at least one tool in " + + "ToolsConfig.ReturnDirectly") + type typedState[M MessageType] struct { Messages []M Extra map[string]any @@ -74,6 +81,15 @@ type typedState[M MessageType] struct { // tCtx.CallID. Entries are inserted at start emission, retained across // interrupt boundaries, and deleted when the matching end span fires. ToolSpansInFlight map[string]*toolSpanInFlight + + // returnDirectlyAllowed records whether this run's graph contains the + // direct-return path, which is what makes a runtime SetReturnDirectly + // observable. + // + // Deliberately unexported: gob skips unexported fields, so this adds nothing + // to the checkpoint wire format. It is therefore lost across a resume and is + // refreshed from live config on every tool iteration. + returnDirectlyAllowed bool } // toolSpanInFlight holds identity for a tool_call_start span that has been @@ -328,6 +344,89 @@ func SendToolGenAction(ctx context.Context, toolName string, action *AgentAction }) } +// setReturnDirectlyInState marks callID as the tool call to return directly on +// whichever concrete agent state the run uses. +// +// found reports whether an agent state was reachable at all; allowed reports +// whether that state permits returning directly, in which case the mark was +// written. +// +// The state is generic over the agent's message type and a tool cannot know +// which one it runs under, so both concrete states are attempted. +func setReturnDirectlyInState(ctx context.Context, callID string) (found, allowed bool) { + mark := func(returnDirectlyAllowed bool, set func()) { + allowed = returnDirectlyAllowed + if allowed { + set() + } + } + + if err := compose.ProcessState(ctx, func(_ context.Context, st *State) error { + mark(st.returnDirectlyAllowed, func() { st.setReturnDirectlyToolCallID(callID) }) + return nil + }); err == nil { + return true, allowed + } + + if err := compose.ProcessState(ctx, func(_ context.Context, st *agenticState) error { + mark(st.returnDirectlyAllowed, func() { st.setReturnDirectlyToolCallID(callID) }) + return nil + }); err == nil { + return true, allowed + } + + return false, false +} + +// SetReturnDirectly signals the ChatModelAgent to stop its ReAct loop once the +// current tool call finishes, and to return that tool call's result as the +// agent's final output. +// +// This is the runtime counterpart of the static ToolsConfig.ReturnDirectly: it +// lets a tool decide from its own execution result whether the loop should stop, +// so the same tool can return directly for some arguments and keep the loop +// running for others. +// +// Where/when to use: +// - Invoke within a tool's Run (Invokable/Streamable) implementation, after the +// tool has determined that its own result is the final answer and no further +// model reasoning is needed. +// - For a Streamable tool, call it before returning the stream reader. +// +// Prerequisite: +// - The agent must be able to reach the direct-return path. That holds when at +// least one tool is listed in ToolsConfig.ReturnDirectly, or when +// ToolsConfig.AllowRuntimeReturnDirectly is set. Otherwise this returns an +// error rather than silently doing nothing. +// +// Priority: +// - Takes priority over ToolsConfig.ReturnDirectly and over +// ChatModelAgentContext.ReturnDirectly set by a BeforeAgent handler. +// +// Concurrency: +// - When tool calls run in parallel and more than one requests a direct return +// in the same iteration, the last request wins. +// +// Limitation: +// - Only usable within ChatModelAgent runs. It relies on ChatModelAgent's +// internal state, which is not available in other agent types. +func SetReturnDirectly(ctx context.Context) error { + callID := compose.GetToolCallID(ctx) + if callID == "" { + return errors.New("must be called within a tool call") + } + + found, allowed := setReturnDirectlyInState(ctx, callID) + if !found { + return errors.New("must be called within a ChatModelAgent tool call: " + + "agent state is unavailable") + } + if !allowed { + return errReturnDirectlyNotAllowed + } + return nil +} + type reactInput struct { Messages []Message } @@ -340,6 +439,11 @@ type typedReactConfig[M MessageType] struct { toolsReturnDirectly map[string]bool + // allowRuntimeReturnDirectly forces the return-direct branch into the graph + // even when no tool is statically configured in toolsReturnDirectly, so that + // SetReturnDirectly can take effect at runtime. + allowRuntimeReturnDirectly bool + agentName string maxIterations int @@ -351,6 +455,16 @@ type typedReactConfig[M MessageType] struct { afterAgentFunc func(ctx context.Context, msg M) (M, error) } +// returnDirectlyReachable reports whether the graph should contain the +// direct-return path: either a tool is statically configured to return directly, +// or the agent explicitly allows a tool to decide at runtime. +// +// Agents that do neither keep their original topology, so they pay nothing for +// this feature. +func (c *typedReactConfig[M]) returnDirectlyReachable() bool { + return len(c.toolsReturnDirectly) > 0 || c.allowRuntimeReturnDirectly +} + type reactConfig = typedReactConfig[*schema.Message] func genToolInfos(ctx context.Context, config *compose.ToolsNodeConfig) ([]*schema.ToolInfo, error) { @@ -384,7 +498,8 @@ func getReturnDirectlyToolCallID(ctx context.Context) (string, bool) { func genReactState(config *reactConfig) func(ctx context.Context) *State { return func(ctx context.Context) *State { st := &State{ - AgentName: config.agentName, + AgentName: config.agentName, + returnDirectlyAllowed: config.returnDirectlyReachable(), } maxIter := 20 if config.maxIterations > 0 { @@ -458,6 +573,10 @@ func newReact(ctx context.Context, config *reactConfig) (reactGraph, error) { toolPreHandle := func(ctx context.Context, _ Message, st *State) (Message, error) { input := st.Messages[len(st.Messages)-1] + // Refreshed from live config on every iteration so that a run resumed from + // a checkpoint written before this field existed still reflects the agent's + // actual topology. + st.returnDirectlyAllowed = config.returnDirectlyReachable() returnDirectly := config.toolsReturnDirectly if execCtx := getTypedChatModelAgentExecCtx[*schema.Message](ctx); execCtx != nil && len(execCtx.runtimeReturnDirectly) > 0 { returnDirectly = execCtx.runtimeReturnDirectly @@ -564,7 +683,7 @@ func newReact(ctx context.Context, config *reactConfig) (reactGraph, error) { _ = g.AddEdge(toolNode_, afterToolCallsNode_) _ = g.AddEdge(afterToolCallsNode_, afterToolCallsCancelCheckNode_) - if len(config.toolsReturnDirectly) > 0 { + if config.returnDirectlyReachable() { const ( toolNodeToEndConverter = "ToolNodeToEndConverter" ) @@ -625,7 +744,8 @@ func getAgenticReturnDirectlyToolCallID(ctx context.Context) (string, bool) { func genAgenticReactState(config *agenticReactConfig) func(ctx context.Context) *agenticState { return func(ctx context.Context) *agenticState { st := &agenticState{ - AgentName: config.agentName, + AgentName: config.agentName, + returnDirectlyAllowed: config.returnDirectlyReachable(), } maxIter := 20 if config.maxIterations > 0 { @@ -707,6 +827,10 @@ func newAgenticReact(ctx context.Context, config *agenticReactConfig) (agenticRe toolPreHandle := func(ctx context.Context, _ *schema.AgenticMessage, st *agenticState) (*schema.AgenticMessage, error) { input := st.Messages[len(st.Messages)-1] + // Refreshed from live config on every iteration so that a run resumed from + // a checkpoint written before this field existed still reflects the agent's + // actual topology. + st.returnDirectlyAllowed = config.returnDirectlyReachable() returnDirectly := config.toolsReturnDirectly if execCtx := getTypedChatModelAgentExecCtx[*schema.AgenticMessage](ctx); execCtx != nil && len(execCtx.runtimeReturnDirectly) > 0 { returnDirectly = execCtx.runtimeReturnDirectly @@ -809,7 +933,7 @@ func newAgenticReact(ctx context.Context, config *agenticReactConfig) (agenticRe _ = g.AddEdge(toolNode_, afterToolCallsNode_) _ = g.AddEdge(afterToolCallsNode_, afterToolCallsCancelCheckNode_) - if len(config.toolsReturnDirectly) > 0 { + if config.returnDirectlyReachable() { const ( toolNodeToEndConverter = "ToolNodeToEndConverter" ) diff --git a/adk/return_directly_dynamic_test.go b/adk/return_directly_dynamic_test.go new file mode 100644 index 000000000..289d07a04 --- /dev/null +++ b/adk/return_directly_dynamic_test.go @@ -0,0 +1,409 @@ +/* + * 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 ( + "context" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" + mockModel "github.com/cloudwego/eino/internal/mock/components/model" + "github.com/cloudwego/eino/schema" +) + +// conditionalReturnTool is a tool whose body decides at runtime whether the +// ReAct loop should stop, by calling SetReturnDirectly. +type conditionalReturnTool struct { + name string + run func(ctx context.Context, argumentsInJSON string) (string, error) +} + +func (t *conditionalReturnTool) Info(_ context.Context) (*schema.ToolInfo, error) { + return &schema.ToolInfo{Name: t.name, Desc: "decides return-directly at runtime"}, nil +} + +func (t *conditionalReturnTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) { + return t.run(ctx, argumentsInJSON) +} + +// TestSetReturnDirectly_AllowRuntimeOnly covers the core new capability: a tool +// stops the loop from its own result with nothing listed in +// ToolsConfig.ReturnDirectly, enabled purely by AllowRuntimeReturnDirectly. +func TestSetReturnDirectly_AllowRuntimeOnly(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cm := mockModel.NewMockToolCallingChatModel(ctrl) + cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() + // Exactly one model call. A second call would mean the loop did not stop. + cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). + Return(schema.AssistantMessage("calling tool", []schema.ToolCall{ + {ID: "call-1", Function: schema.FunctionCall{Name: "dyn"}}, + }), nil). + Times(1) + + var setErr error + agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{ + Name: "TestAgent", + Description: "agent whose tool stops the loop at runtime", + Model: cm, + ToolsConfig: ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{&conditionalReturnTool{ + name: "dyn", + run: func(ctx context.Context, _ string) (string, error) { + setErr = SetReturnDirectly(ctx) + return "final from tool", nil + }, + }}, + }, + // No ReturnDirectly entries: the switch alone must be enough. + AllowRuntimeReturnDirectly: true, + }, + }) + require.NoError(t, err) + + events, _ := drainEvents(agent.Run(ctx, &AgentInput{ + Messages: []Message{schema.UserMessage("go")}, + })) + require.NoError(t, setErr) + require.NotEmpty(t, events) + + for _, ev := range events { + require.NoError(t, ev.Err) + } + + last := events[len(events)-1] + require.NotNil(t, last.Output) + require.NotNil(t, last.Output.MessageOutput) + msg := last.Output.MessageOutput.Message + require.NotNil(t, msg) + assert.Equal(t, schema.Tool, msg.Role) + assert.Equal(t, "dyn", msg.ToolName) + assert.Equal(t, "final from tool", msg.Content) +} + +// TestSetReturnDirectly_NotAllowed pins the guard: when the agent has no +// direct-return path at all, SetReturnDirectly reports an error instead of +// silently doing nothing, and the loop keeps running. +func TestSetReturnDirectly_NotAllowed(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cm := mockModel.NewMockToolCallingChatModel(ctrl) + cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() + cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). + Return(schema.AssistantMessage("calling tool", []schema.ToolCall{ + {ID: "call-1", Function: schema.FunctionCall{Name: "dyn"}}, + }), nil). + Times(1) + // The loop must continue, so the model is asked for a final answer. + cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). + Return(schema.AssistantMessage("final from model", nil), nil). + Times(1) + + var setErr error + agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{ + Name: "TestAgent", + Description: "agent that does not allow returning directly", + Model: cm, + ToolsConfig: ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{&conditionalReturnTool{ + name: "dyn", + run: func(ctx context.Context, _ string) (string, error) { + setErr = SetReturnDirectly(ctx) + return "intermediate", nil + }, + }}, + }, + // Neither ReturnDirectly nor AllowRuntimeReturnDirectly. + }, + }) + require.NoError(t, err) + + events, _ := drainEvents(agent.Run(ctx, &AgentInput{ + Messages: []Message{schema.UserMessage("go")}, + })) + require.ErrorIs(t, setErr, errReturnDirectlyNotAllowed) + require.NotEmpty(t, events) + + for _, ev := range events { + require.NoError(t, ev.Err) + } + + last := events[len(events)-1] + require.NotNil(t, last.Output) + require.NotNil(t, last.Output.MessageOutput) + msg := last.Output.MessageOutput.Message + require.NotNil(t, msg) + assert.Equal(t, schema.Assistant, msg.Role) + assert.Equal(t, "final from model", msg.Content) +} + +// TestSetReturnDirectly_OverridesStaticSelection verifies the runtime decision +// wins over the static one: "cfg" is the configured return-directly tool, but +// "dyn" claims the direct return at runtime, so "dyn"'s result is returned. +func TestSetReturnDirectly_OverridesStaticSelection(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cm := mockModel.NewMockToolCallingChatModel(ctrl) + cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() + cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()). + Return(schema.AssistantMessage("calling tools", []schema.ToolCall{ + {ID: "call-cfg", Function: schema.FunctionCall{Name: "cfg"}}, + {ID: "call-dyn", Function: schema.FunctionCall{Name: "dyn"}}, + }), nil). + Times(1) + + var setErr error + agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{ + Name: "TestAgent", + Description: "runtime decision overrides the configured one", + Model: cm, + ToolsConfig: ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{ + &conditionalReturnTool{ + name: "cfg", + run: func(_ context.Context, _ string) (string, error) { + return "from cfg", nil + }, + }, + &conditionalReturnTool{ + name: "dyn", + run: func(ctx context.Context, _ string) (string, error) { + setErr = SetReturnDirectly(ctx) + return "from dyn", nil + }, + }, + }, + }, + ReturnDirectly: map[string]bool{"cfg": true}, + }, + }) + require.NoError(t, err) + + events, _ := drainEvents(agent.Run(ctx, &AgentInput{ + Messages: []Message{schema.UserMessage("go")}, + })) + require.NoError(t, setErr) + require.NotEmpty(t, events) + + for _, ev := range events { + require.NoError(t, ev.Err) + } + + last := events[len(events)-1] + require.NotNil(t, last.Output) + require.NotNil(t, last.Output.MessageOutput) + msg := last.Output.MessageOutput.Message + require.NotNil(t, msg) + assert.Equal(t, "dyn", msg.ToolName) + assert.Equal(t, "from dyn", msg.Content) +} + +// streamingReturnTool requests a direct return from a StreamableRun tool, which +// the event-sender wrapper handles on a separate code path from InvokableRun. +type streamingReturnTool struct { + name string + setErr func(error) + chunks []string +} + +func (t *streamingReturnTool) Info(_ context.Context) (*schema.ToolInfo, error) { + return &schema.ToolInfo{Name: t.name, Desc: "streams and stops the loop"}, nil +} + +func (t *streamingReturnTool) StreamableRun(ctx context.Context, _ string, _ ...tool.Option) (*schema.StreamReader[string], error) { + // Must be called synchronously, before returning the reader: the wrapper + // inspects state right after this function returns. + t.setErr(SetReturnDirectly(ctx)) + + sr, sw := schema.Pipe[string](len(t.chunks)) + go func() { + defer sw.Close() + for _, c := range t.chunks { + sw.Send(c, nil) + } + }() + return sr, nil +} + +func TestSetReturnDirectly_StreamableTool(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cm := mockModel.NewMockToolCallingChatModel(ctrl) + cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes() + cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []*schema.Message, _ ...any) (*schema.StreamReader[*schema.Message], error) { + sr, sw := schema.Pipe[*schema.Message](1) + go func() { + defer sw.Close() + sw.Send(schema.AssistantMessage("calling tool", []schema.ToolCall{ + {ID: "call-1", Function: schema.FunctionCall{Name: "dyn_stream"}}, + }), nil) + }() + return sr, nil + }). + Times(1) + + var setErr error + agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{ + Name: "TestAgent", + Description: "streaming tool that stops the loop at runtime", + Model: cm, + ToolsConfig: ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{&streamingReturnTool{ + name: "dyn_stream", + chunks: []string{"fi", "nal"}, + setErr: func(e error) { setErr = e }, + }}, + }, + AllowRuntimeReturnDirectly: true, + }, + }) + require.NoError(t, err) + + events, _ := drainEvents(agent.Run(ctx, &AgentInput{ + Messages: []Message{schema.UserMessage("go")}, + EnableStreaming: true, + })) + require.NoError(t, setErr) + require.NotEmpty(t, events) + + for _, ev := range events { + require.NoError(t, ev.Err) + } + + last := events[len(events)-1] + require.NotNil(t, last.Output) + require.NotNil(t, last.Output.MessageOutput) + + mo := last.Output.MessageOutput + content := "" + toolName := "" + if mo.IsStreaming { + for { + chunk, recvErr := mo.MessageStream.Recv() + if recvErr != nil { + break + } + content += chunk.Content + if chunk.ToolName != "" { + toolName = chunk.ToolName + } + } + } else { + require.NotNil(t, mo.Message) + content = mo.Message.Content + toolName = mo.Message.ToolName + } + + assert.Equal(t, "dyn_stream", toolName) + assert.Equal(t, "final", content) +} + +// TestSetReturnDirectly_AgenticPath covers the same runtime decision on the +// *schema.AgenticMessage path, which builds its graph separately. +func TestSetReturnDirectly_AgenticPath(t *testing.T) { + ctx := context.Background() + + mdl := &sequentialAgenticModel{ + responses: []*schema.AgenticMessage{ + agenticToolCallMsg("dyn", "call-1", `"args"`), + }, + } + + var setErr error + agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{ + Name: t.Name(), + Description: "agentic agent whose tool stops the loop at runtime", + Model: mdl, + ToolsConfig: ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{&conditionalReturnTool{ + name: "dyn", + run: func(ctx context.Context, _ string) (string, error) { + setErr = SetReturnDirectly(ctx) + return "final from tool", nil + }, + }}, + }, + AllowRuntimeReturnDirectly: true, + }, + }) + require.NoError(t, err) + + runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{ + Agent: agent, EnableStreaming: false, + }) + events := drainAgenticEvents(runner.Query(ctx, "go")) + require.NoError(t, setErr) + require.NoError(t, firstAgenticEventError(events)) + + // The model must not be called a second time. + assert.Equal(t, int32(1), atomic.LoadInt32(&mdl.callCount)) + + last := lastAgenticEvent(events) + require.NotNil(t, last) + require.NotNil(t, last.Output) + require.NotNil(t, last.Output.MessageOutput) + msg := last.Output.MessageOutput.Message + require.NotNil(t, msg) + require.GreaterOrEqual(t, len(msg.ContentBlocks), 1) + ftr := msg.ContentBlocks[0].FunctionToolResult + require.NotNil(t, ftr, "expected FunctionToolResult, got type=%v", msg.ContentBlocks[0].Type) + assert.Equal(t, "call-1", ftr.CallID) +} + +func TestSetReturnDirectly_OutsideToolCall(t *testing.T) { + require.ErrorContains(t, SetReturnDirectly(context.Background()), + "must be called within a tool call") +} + +// TestReturnDirectlyReachable pins when the direct-return path is built, which is +// what keeps agents that use neither mechanism on their original topology. +func TestReturnDirectlyReachable(t *testing.T) { + t.Run("NeitherConfigured", func(t *testing.T) { + c := &reactConfig{} + assert.False(t, c.returnDirectlyReachable()) + }) + + t.Run("StaticOnly", func(t *testing.T) { + c := &reactConfig{toolsReturnDirectly: map[string]bool{"a": true}} + assert.True(t, c.returnDirectlyReachable()) + }) + + t.Run("RuntimeOnly", func(t *testing.T) { + c := &reactConfig{allowRuntimeReturnDirectly: true} + assert.True(t, c.returnDirectlyReachable()) + }) +}