diff --git a/fragment.go b/fragment.go index 0f644e1..2b3820a 100644 --- a/fragment.go +++ b/fragment.go @@ -219,18 +219,18 @@ func (f Fragment) LastMessage() *openai.ChatCompletionMessage { return &f.Messages[len(f.Messages)-1] } -func (f Fragment) LastAssistantMessages() []openai.ChatCompletionMessage { +func (f Fragment) LastAssistantAndToolMessages() []openai.ChatCompletionMessage { lastMessages := []openai.ChatCompletionMessage{} found := false for i := len(f.Messages) - 1; i >= 0; i-- { - if f.Messages[i].Role == "assistant" { + if f.Messages[i].Role == "assistant" || f.Messages[i].Role == "tool" { found = true lastMessages = append([]openai.ChatCompletionMessage{f.Messages[i]}, lastMessages...) } - if found && f.Messages[i].Role != "assistant" { + if found && (f.Messages[i].Role != "assistant" && f.Messages[i].Role != "tool") { break } } diff --git a/fragment_e2e_test.go b/fragment_e2e_test.go index 9af7fbd..4cdf37a 100644 --- a/fragment_e2e_test.go +++ b/fragment_e2e_test.go @@ -79,11 +79,11 @@ var _ = Describe("Fragment test", func() { fragment = fragment.AddMessage("assistant", "Byee!") Expect(len(fragment.Messages)).To(Equal(4)) - Expect(len(fragment.LastAssistantMessages())).To(Equal(2)) - Expect(fragment.LastAssistantMessages()[0].Content).To(Equal("Hi!")) - Expect(fragment.LastAssistantMessages()[1].Content).To(Equal("Byee!")) + Expect(len(fragment.LastAssistantAndToolMessages())).To(Equal(2)) + Expect(fragment.LastAssistantAndToolMessages()[0].Content).To(Equal("Hi!")) + Expect(fragment.LastAssistantAndToolMessages()[1].Content).To(Equal("Byee!")) conv := NewEmptyFragment() - conv.Messages = append(conv.Messages, fragment.LastAssistantMessages()...) + conv.Messages = append(conv.Messages, fragment.LastAssistantAndToolMessages()...) Expect(conv.Messages[0].Content).To(Equal("Hi!")) Expect(conv.Messages[1].Content).To(Equal("Byee!")) diff --git a/fragment_test.go b/fragment_test.go index 2e3e310..60e092b 100644 --- a/fragment_test.go +++ b/fragment_test.go @@ -29,11 +29,11 @@ var _ = Describe("Fragment test", func() { fragment = fragment.AddMessage("assistant", "Byee!") Expect(len(fragment.Messages)).To(Equal(4)) - Expect(len(fragment.LastAssistantMessages())).To(Equal(2)) - Expect(fragment.LastAssistantMessages()[0].Content).To(Equal("Hi!")) - Expect(fragment.LastAssistantMessages()[1].Content).To(Equal("Byee!")) + Expect(len(fragment.LastAssistantAndToolMessages())).To(Equal(2)) + Expect(fragment.LastAssistantAndToolMessages()[0].Content).To(Equal("Hi!")) + Expect(fragment.LastAssistantAndToolMessages()[1].Content).To(Equal("Byee!")) conv := NewEmptyFragment() - conv.Messages = append(conv.Messages, fragment.LastAssistantMessages()...) + conv.Messages = append(conv.Messages, fragment.LastAssistantAndToolMessages()...) Expect(conv.Messages[0].Content).To(Equal("Hi!")) Expect(conv.Messages[1].Content).To(Equal("Byee!")) diff --git a/guidelines.go b/guidelines.go index aff0144..a0b503a 100644 --- a/guidelines.go +++ b/guidelines.go @@ -6,6 +6,7 @@ import ( "github.com/mudler/cogito/prompt" "github.com/mudler/cogito/structures" + "github.com/sashabaranov/go-openai" ) type Guidelines []Guideline @@ -99,7 +100,7 @@ func GetRelevantGuidelines(llm LLM, guidelines Guidelines, fragment Fragment, op return g, nil } -func usableTools(llm LLM, fragment Fragment, opts ...Option) (Tools, Guidelines, error) { +func usableTools(llm LLM, fragment Fragment, opts ...Option) (Tools, Guidelines, []openai.ChatCompletionMessage, error) { o := defaultOptions() o.Apply(opts...) @@ -107,15 +108,23 @@ func usableTools(llm LLM, fragment Fragment, opts ...Option) (Tools, Guidelines, tools := slices.Clone(o.tools) guidelines := o.guidelines + prompts := []openai.ChatCompletionMessage{} for _, session := range o.mcpSessions { mcpTools, err := mcpToolsFromTransport(o.context, session) if err != nil { - return Tools{}, Guidelines{}, fmt.Errorf("failed to get MCP tools: %w", err) + return Tools{}, Guidelines{}, nil, fmt.Errorf("failed to get MCP tools: %w", err) } for _, tool := range mcpTools { tools = append(tools, tool) } + toolPrompts, err := mcpPromptsFromTransport(o.context, session, o.mcpArgs) + if err != nil { + return Tools{}, Guidelines{}, nil, fmt.Errorf("failed to get MCP prompts: %w", err) + } + if o.mcpPrompts { + prompts = append(prompts, toolPrompts...) + } } if len(o.guidelines) > 0 { @@ -125,12 +134,12 @@ func usableTools(llm LLM, fragment Fragment, opts ...Option) (Tools, Guidelines, var err error guidelines, err = GetRelevantGuidelines(llm, o.guidelines, fragment, opts...) if err != nil { - return Tools{}, Guidelines{}, fmt.Errorf("failed to get relevant guidelines: %w", err) + return Tools{}, Guidelines{}, nil, fmt.Errorf("failed to get relevant guidelines: %w", err) } for _, guideline := range guidelines { tools = append(tools, guideline.Tools...) } } - return tools, guidelines, nil + return tools, guidelines, prompts, nil } diff --git a/mcp.go b/mcp.go index ec92de3..2b77e4e 100644 --- a/mcp.go +++ b/mcp.go @@ -69,6 +69,33 @@ type toolInputSchema struct { Required []string `json:"required,omitempty"` } +func mcpPromptsFromTransport(ctx context.Context, session *mcp.ClientSession, arguments map[string]string) ([]openai.ChatCompletionMessage, error) { + prompts, err := session.ListPrompts(ctx, nil) + if err != nil { + return nil, err + } + + promptsList := []openai.ChatCompletionMessage{} + + for _, prompt := range prompts.Prompts { + p, err := session.GetPrompt(ctx, &mcp.GetPromptParams{Name: prompt.Name, Arguments: arguments}) + if err != nil { + return nil, err + } + for _, message := range p.Messages { + switch message.Content.(type) { + case *mcp.TextContent: + promptsList = append(promptsList, openai.ChatCompletionMessage{ + Role: string(message.Role), + Content: message.Content.(*mcp.TextContent).Text, + }) + } + } + } + + return promptsList, nil +} + // probe the MCP remote and generate tools that are compliant with cogito func mcpToolsFromTransport(ctx context.Context, session *mcp.ClientSession) ([]*mcpTool, error) { allTools := []*mcpTool{} diff --git a/options.go b/options.go index 25900de..5ef4dc7 100644 --- a/options.go +++ b/options.go @@ -16,6 +16,8 @@ type Options struct { deepContext bool toolReasoner bool toolReEvaluator bool + autoPlan bool + planReEvaluator bool statusCallback func(string) gaps []string context context.Context @@ -27,6 +29,8 @@ type Options struct { strictGuidelines bool mcpSessions []*mcp.ClientSession guidelines Guidelines + mcpPrompts bool + mcpArgs map[string]string } type Option func(*Options) @@ -75,6 +79,21 @@ var ( EnableStrictGuidelines Option = func(o *Options) { o.strictGuidelines = true } + + // EnableAutoPlan enables cogito to automatically use planning if needed + EnableAutoPlan Option = func(o *Options) { + o.autoPlan = true + } + + // EnableAutoPlanReEvaluator enables cogito to automatically re-evaluate the need to use planning + EnableAutoPlanReEvaluator Option = func(o *Options) { + o.planReEvaluator = true + } + + // EnableMCPPrompts enables the use of MCP prompts + EnableMCPPrompts Option = func(o *Options) { + o.mcpPrompts = true + } ) // WithIterations allows to set the number of refinement iterations @@ -166,3 +185,10 @@ func WithMCPs(sessions ...*mcp.ClientSession) func(o *Options) { o.mcpSessions = append(o.mcpSessions, sessions...) } } + +// WithMCPArgs sets the arguments for the MCP prompts +func WithMCPArgs(args map[string]string) func(o *Options) { + return func(o *Options) { + o.mcpArgs = args + } +} diff --git a/plan.go b/plan.go index 0de7384..c30a49a 100644 --- a/plan.go +++ b/plan.go @@ -167,7 +167,7 @@ func ExecutePlan(llm LLM, conv Fragment, plan *structures.Plan, goal *structures return Fragment{}, nil } - conv.Messages = append(conv.Messages, subtaskConvResult.LastAssistantMessages()...) + conv.Messages = append(conv.Messages, subtaskConvResult.LastAssistantAndToolMessages()...) conv.Status.Iterations = conv.Status.Iterations + 1 conv.Status.ToolsCalled = append(conv.Status.ToolsCalled, subtaskConvResult.Status.ToolsCalled...) conv.Status.ToolResults = append(conv.Status.ToolResults, subtaskConvResult.Status.ToolResults...) diff --git a/plan_test.go b/plan_test.go index b7fc096..5f6aa49 100644 --- a/plan_test.go +++ b/plan_test.go @@ -145,12 +145,17 @@ var _ = Describe("Plannings with tools", func() { ContainSubstring("What is photosynthesis?"), ) - Expect(len(result.Messages)).To(Equal(3)) + Expect(len(result.Status.ToolResults)).To(Equal(2)) + Expect(len(result.Status.ToolsCalled)).To(Equal(2)) + Expect(len(result.Messages)).To(Equal(5)) + Expect(result.Messages[0].Content).To(Equal("What is photosynthesis?")) Expect(result.Messages[1].ToolCalls[0].Function.Arguments).To(Equal("{\"query\": \"chlorophyll\"}")) Expect(result.Messages[1].ToolCalls[0].Function.Name).To(Equal("search")) - Expect(result.Messages[2].ToolCalls[0].Function.Arguments).To(Equal("{\"query\": \"photosynthesis\"}")) - Expect(result.Messages[2].ToolCalls[0].Function.Name).To(Equal("search")) + Expect(result.Messages[2].Content).To(Equal("Chlorophyll is a green pigment found in plants.")) + Expect(result.Messages[3].ToolCalls[0].Function.Arguments).To(Equal("{\"query\": \"photosynthesis\"}")) + Expect(result.Messages[3].ToolCalls[0].Function.Name).To(Equal("search")) + Expect(result.Messages[4].Content).To(Equal("Photosynthesis is the process by which plants convert sunlight into energy.")) }) }) }) diff --git a/prompt/prompt.go b/prompt/prompt.go index 6a1f854..d14c9f7 100644 --- a/prompt/prompt.go +++ b/prompt/prompt.go @@ -16,6 +16,7 @@ const ( PromptPlanExecutionType PromptType = iota PromptGuidelinesType PromptType = iota PromptGuidelinesExtractionType PromptType = iota + PromptPlanDecisionType PromptType = iota ) var ( @@ -33,6 +34,7 @@ var ( PromptPlanExecutionType: PromptPlanExecution, PromptGuidelinesType: PromptGuidelines, PromptGuidelinesExtractionType: PromptGuidelinesExtraction, + PromptPlanDecisionType: DecideIfPlanningIsNeeded, } PromptGuidelinesExtraction = NewPrompt("What guidelines should be applied? return only the numbers of the guidelines by using the json tool with a list of integers corresponding to the guidelines.") @@ -281,4 +283,25 @@ Context: You will use the "json" tool with the option "extract_boolean" set to either yes or no. Reply with the appropriate boolean extraction tool with yes or no, based on the context. If the context speaks about, let's say doing something, you will replay with yes, or a no otherwise.`) + + DecideIfPlanningIsNeeded = NewPrompt(`You are an AI assistant that decides if planning and executing subtasks in sequence is needed from a conversation. + +Conversation: +{{.Context}} + +{{if ne .AdditionalContext ""}} +AdditionalContext: +{{.AdditionalContext}} +{{end}} + +Available tools: +{{ range $index, $tool := .Tools }} +- Tool name: "{{$tool.Name}}" + Tool description: {{$tool.Description}} + Tool arguments: {{$tool.Parameters | toJson}} +{{ end }} + +Based on the conversation, context, and available tools, decide if planning and executing subtasks in sequence is needed. +Keep in mind that Planning will later involve in breaking down the problem into a set of subtasks that require running tools in sequence and evaluating their results. +If you think planning is needed, reply with yes, otherwise reply with no.`) ) diff --git a/tools.go b/tools.go index f76bd0e..28fe7d3 100644 --- a/tools.go +++ b/tools.go @@ -63,7 +63,7 @@ func ToolReasoner(llm LLM, f Fragment, opts ...Option) (Fragment, error) { prompter := o.prompts.GetPrompt(prompt.ToolReasonerType) - tools, guidelines, err := usableTools(llm, f, opts...) + tools, guidelines, prompts, err := usableTools(llm, f, opts...) if err != nil { return Fragment{}, fmt.Errorf("failed to get relevant guidelines: %w", err) } @@ -87,7 +87,84 @@ func ToolReasoner(llm LLM, f Fragment, opts ...Option) (Fragment, error) { return Fragment{}, fmt.Errorf("failed to render tool reasoner prompt: %w", err) } - return llm.Ask(o.context, NewEmptyFragment().AddMessage("user", prompt)) + fragment := NewEmptyFragment().AddMessage("user", prompt) + + for _, prompt := range prompts { + fragment = fragment.AddStartMessage(prompt.Role, prompt.Content) + } + + return llm.Ask(o.context, fragment) +} + +func decideToPlan(llm LLM, f Fragment, tools Tools, opts ...Option) (bool, error) { + o := defaultOptions() + o.Apply(opts...) + + prompter := o.prompts.GetPrompt(prompt.PromptPlanDecisionType) + + additionalContext := "" + if f.ParentFragment != nil { + if o.deepContext { + additionalContext = f.ParentFragment.AllFragmentsStrings() + } else { + additionalContext = f.ParentFragment.String() + } + } + + xlog.Debug("definitions", "tools", tools.Definitions()) + prompt, err := prompter.Render( + struct { + Context string + Tools []*openai.FunctionDefinition + AdditionalContext string + }{ + Context: f.String(), + Tools: tools.Definitions(), + AdditionalContext: additionalContext, + }, + ) + if err != nil { + return false, fmt.Errorf("failed to render content improver prompt: %w", err) + } + + planDecision, err := llm.Ask(o.context, NewEmptyFragment().AddMessage("user", prompt)) + if err != nil { + return false, fmt.Errorf("failed to ask LLM for plan decision: %w", err) + } + + boolean, err := ExtractBoolean(llm, planDecision, opts...) + if err != nil { + return false, fmt.Errorf("failed extracting boolean: %w", err) + } + + return boolean.Boolean, nil +} + +func doPlan(llm LLM, f Fragment, tools Tools, opts ...Option) (Fragment, bool, error) { + planDecision, err := decideToPlan(llm, f, tools, opts...) + if err != nil { + return f, false, fmt.Errorf("failed to decide if planning is needed: %w", err) + } + if planDecision { + xlog.Debug("Planning is needed") + goal, err := ExtractGoal(llm, f, opts...) + if err != nil { + return f, false, fmt.Errorf("failed to extract goal: %w", err) + } + plan, err := ExtractPlan(llm, f, goal, opts...) + if err != nil { + return f, false, fmt.Errorf("failed to extract plan: %w", err) + } + // opts without autoplan disabled + f, err = ExecutePlan(llm, f, plan, goal, append(opts, func(o *Options) { o.autoPlan = false })...) + if err != nil { + return f, false, fmt.Errorf("failed to execute plan: %w", err) + } + + return f, true, nil + } + + return f, false, nil } // ExecuteTools runs a fragment through an LLM, and executes Tools. It returns a new fragment with the tool result at the end @@ -119,6 +196,27 @@ func ExecuteTools(llm LLM, f Fragment, opts ...Option) (Fragment, error) { } } + // should I plan? + if o.autoPlan { + xlog.Debug("Checking if planning is needed") + tools, _, _, err := usableTools(llm, f, opts...) + if err != nil { + return Fragment{}, fmt.Errorf("failed to get relevant guidelines: %w", err) + } + var executedPlan bool + // Decide if planning is needed and execute it + f, executedPlan, err = doPlan(llm, f, tools, opts...) + if err != nil { + return Fragment{}, fmt.Errorf("failed to decide if planning is needed: %w", err) + } + if executedPlan { + xlog.Debug("Plan was executed") + } else { + xlog.Debug("Planning is not needed") + } + //return f, nil + } + i := 0 if o.maxIterations <= 0 { o.maxIterations = 1 @@ -131,11 +229,28 @@ func ExecuteTools(llm LLM, f Fragment, opts ...Option) (Fragment, error) { i++ // get guidelines and tools for the current fragment - tools, guidelines, err := usableTools(llm, f, opts...) + tools, guidelines, toolPrompts, err := usableTools(llm, f, opts...) if err != nil { return Fragment{}, fmt.Errorf("failed to get relevant guidelines: %w", err) } + // check if I would need toplan? + if o.autoPlan && o.planReEvaluator { + xlog.Debug("Checking if planning is needed") + // Decide if planning is needed + var executedPlan bool + f, executedPlan, err = doPlan(llm, f, tools, opts...) + if err != nil { + return Fragment{}, fmt.Errorf("failed to decide if planning is needed: %w", err) + } + if executedPlan { + xlog.Debug("Plan was executed") + continue + } else { + xlog.Debug("Planning is not needed") + } + } + // If we don't have gaps, we analyze the content to find some prompter := o.prompts.GetPrompt(prompt.ToolSelectorType) @@ -169,7 +284,11 @@ func ExecuteTools(llm LLM, f Fragment, opts ...Option) (Fragment, error) { } xlog.Debug("Selecting tool") - toolReasoning, err := llm.Ask(o.context, NewEmptyFragment().AddMessage("user", prompt)) + fragment := NewEmptyFragment().AddMessage("user", prompt) + for _, prompt := range toolPrompts { + fragment = fragment.AddStartMessage(prompt.Role, prompt.Content) + } + toolReasoning, err := llm.Ask(o.context, fragment) if err != nil { return Fragment{}, fmt.Errorf("failed to ask LLM for tool selection: %w", err) } @@ -188,7 +307,10 @@ func ExecuteTools(llm LLM, f Fragment, opts ...Option) (Fragment, error) { if selectedToolResult == nil { xlog.Debug("No tool selected by the LLM") - return f, ErrNoToolSelected + if len(f.Status.ToolsCalled) == 0 { + return f, ErrNoToolSelected + } + return f, nil } xlog.Debug("Picked tool with args", "result", selectedToolResult) diff --git a/tools_test.go b/tools_test.go index 6d79eec..9814b37 100644 --- a/tools_test.go +++ b/tools_test.go @@ -7,6 +7,7 @@ import ( "github.com/mudler/cogito/tests/mock" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/sashabaranov/go-openai" ) var _ = Describe("ExecuteTools", func() { @@ -338,5 +339,139 @@ var _ = Describe("ExecuteTools", func() { Expect(result.Status.ToolResults[2].Result).To(Equal("Baz is a plant that grows on the ground.")) }) + It("should execute autoplan basic functionality", func() { + mockTool := mock.NewMockTool("search", "Search for information") + + // Mock planning decision - decide that planning is needed + mockLLM.SetAskResponse("Yes, this task requires planning to be completed effectively.") + mockLLM.AddCreateChatCompletionFunction("json", `{"extract_boolean": true}`) + + // Mock goal extraction + mockLLM.SetAskResponse("The goal is to research information about photosynthesis.") + mockLLM.AddCreateChatCompletionFunction("json", `{"goal": "Research information about photosynthesis"}`) + + // Mock plan creation (first step of plan extraction) + mockLLM.SetAskResponse("Here is a plan with subtasks: 1. Search for basic information about photosynthesis") + + // Mock subtask extraction (second step of plan extraction) - this uses CreateChatCompletion + mockLLM.AddCreateChatCompletionFunction("json", `{"subtasks": ["Search for basic information about photosynthesis"]}`) + + // Mock first subtask execution - search + mockLLM.SetAskResponse("I need to search for information about photosynthesis.") + mockLLM.AddCreateChatCompletionFunction("search", `{"query": "photosynthesis basics"}`) + mockTool.SetRunResult("Photosynthesis is the process by which plants convert sunlight into energy.") + mockLLM.SetAskResponse("I want to stop using tools.") + mockLLM.AddCreateChatCompletionFunction("json", `{"extract_boolean": true}`) + + // Mock goal achievement check for first subtask + mockLLM.SetAskResponse("No need to execute tools") + mockLLM.SetCreateChatCompletionResponse(openai.ChatCompletionResponse{ + Choices: []openai.ChatCompletionChoice{ + { + Message: openai.ChatCompletionMessage{ + Role: "assistant", + Content: "No need to execute tools", + }, + }, + }, + }) + + result, err := ExecuteTools(mockLLM, originalFragment, + EnableAutoPlan, + WithTools(mockTool)) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Verify that planning was executed by checking fragment history + Expect(len(mockLLM.FragmentHistory)).To(BeNumerically("==", 6), fmt.Sprintf("Fragment history: %v", mockLLM.FragmentHistory)) + + // Check that planning decision was made + Expect(mockLLM.FragmentHistory[0].String()).To( + And( + ContainSubstring("You are an AI assistant that decides if planning and executing subtasks in sequence is needed from a conversation"), + ContainSubstring("What is photosynthesis"), + )) + + // Check that goal extraction was called + Expect(mockLLM.FragmentHistory[1].String()).To( + ContainSubstring("Analyze the following text and the context to identify the goal")) + + // Check that plan creation was called + Expect(mockLLM.FragmentHistory[2].String()).To( + ContainSubstring("You are an AI assistant that breaks down a goal into a series of actionable steps")) + + // Check that subtask extraction was called + Expect(mockLLM.FragmentHistory[3].String()).To( + ContainSubstring("You are an AI assistant that needs to decide if to use a tool in a conversation")) + + // Check that first subtask was executed + Expect(mockLLM.FragmentHistory[4].String()).To( + ContainSubstring("Search for basic information about photosynthesis")) + + Expect(mockLLM.FragmentHistory[5].String()).To( + And( + ContainSubstring("You are an AI assistant that needs to decide if to use a tool in a conversation."), + ContainSubstring("What is photosynthesis"), + ContainSubstring("Photosynthesis is the process by which plants convert sunlight into energy"), + ContainSubstring(`search({"query": "photosynthesis basics"})`), + ContainSubstring("Photosynthesis is the process by which plants convert sunlight into energy."), + )) + Expect(result.Messages[len(result.Messages)-1].Content).To( + And( + ContainSubstring("Photosynthesis is the process by which plants convert sunlight into energy."), + ), + fmt.Sprintf("Result: %+v", result), + ) + + // Verify tools were called correctly + Expect(len(result.Status.ToolsCalled)).To(Equal(1)) + Expect(len(result.Status.ToolResults)).To(Equal(1)) + + Expect(result.Status.ToolResults[0].Executed).To(BeTrue()) + Expect(result.Status.ToolResults[0].Name).To(Equal("search")) + Expect(result.Status.ToolResults[0].Result).To(Equal("Photosynthesis is the process by which plants convert sunlight into energy.")) + }) + + It("should not execute autoplan when planning is not needed", func() { + mockTool := mock.NewMockTool("search", "Search for information") + + // Mock planning decision - decide that planning is NOT needed + mockLLM.SetAskResponse("No, this task does not require planning.") + mockLLM.AddCreateChatCompletionFunction("json", `{"extract_boolean": false}`) + + // Mock regular tool execution (since planning is not needed, it falls back to normal tool execution) + mockLLM.SetAskResponse("I need to search for information about photosynthesis.") + mockLLM.AddCreateChatCompletionFunction("search", `{"query": "photosynthesis"}`) + mockTool.SetRunResult("Photosynthesis is the process by which plants convert sunlight into energy.") + mockLLM.SetAskResponse("I want to stop using tools.") + mockLLM.AddCreateChatCompletionFunction("json", `{"extract_boolean": false}`) + + result, err := ExecuteTools(mockLLM, originalFragment, + EnableAutoPlan, + WithTools(mockTool)) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Verify that planning decision was made but no plan was executed + Expect(len(mockLLM.FragmentHistory)).To(BeNumerically(">=", 2), fmt.Sprintf("Fragment history: %v", mockLLM.FragmentHistory)) + + // Check that planning decision was made + Expect(mockLLM.FragmentHistory[0].String()).To( + And( + ContainSubstring("You are an AI assistant that decides if planning and executing subtasks in sequence is needed from a conversation"), + ContainSubstring("What is photosynthesis"), + )) + + // Check that tools were called (regular tool execution, not planning) + Expect(len(result.Status.ToolsCalled)).To(Equal(1)) + Expect(len(result.Status.ToolResults)).To(Equal(1)) + + Expect(result.Status.ToolResults[0].Executed).To(BeTrue()) + Expect(result.Status.ToolResults[0].Name).To(Equal("search")) + Expect(result.Status.ToolResults[0].Result).To(Equal("Photosynthesis is the process by which plants convert sunlight into energy.")) + }) + }) })