Files
cogito/fragment.go
Ettore Di Giacinto 2324131eaa feat: add state and feedback (#18)
* feat: allow to adjust tool call with user feedback

Expand the callback signature such as we accept a string for adjusting
the toolcall. If an adjustment is provided and we should continue, then
cogito prompts again the selection process with the user feedback to
improve the tool call.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat: return a state such as the execution can be resumed

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: style changes

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: add tests

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-12-07 23:42:24 +01:00

332 lines
9.1 KiB
Go

package cogito
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"github.com/mudler/cogito/pkg/xlog"
"github.com/mudler/cogito/structures"
"github.com/sashabaranov/go-openai"
)
type Status struct {
Iterations int
ToolsCalled Tools
ToolResults []ToolStatus
Plans []PlanStatus
PastActions []ToolStatus // Track past actions for loop detections
ReasoningLog []string // Track reasoning for each iteration
}
type Fragment struct {
Messages []openai.ChatCompletionMessage
ParentFragment *Fragment
Status *Status
Multimedia []Multimedia
}
// Messages returns the chat completion messages from this fragment,
// automatically prepending a force-text-reply system message if tool calls are detected.
// This ensures LLMs provide natural language responses instead of JSON tool syntax
// when Ask() is called after ExecuteTools().
func (f Fragment) GetMessages() []openai.ChatCompletionMessage {
// TODO: this is kinda of brittle because LLM interface implementers needs to call this methods to get the messages,
// but we don't enforce it - worse is that we change the user messages and they might not expect that when calling GetMessages().
// We should move away from this, and have a more explicit way to get the messages.
messages := f.Messages
// Check if conversation contains tool calls
hasToolCalls := false
for _, msg := range messages {
if len(msg.ToolCalls) > 0 {
hasToolCalls = true
break
}
}
// If tool calls detected, prepend instruction for text-only reply
// This prevents the LLM from outputting JSON tool syntax like:
// [{"index":0,"type":"function","function":{"name":"tool","arguments":"..."}}]
if hasToolCalls {
// Reply to the user without using any tools or function calls. Just reply with the message.
forceTextReply := "Provide a natural language response to the user. Do not use any tools or function calls in your reply."
messages = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: forceTextReply,
},
}, messages...)
}
return messages
}
func NewEmptyFragment() Fragment {
return Fragment{
Status: &Status{
PastActions: []ToolStatus{},
ReasoningLog: []string{},
ToolsCalled: Tools{},
ToolResults: []ToolStatus{},
},
}
}
func NewFragment(messages ...openai.ChatCompletionMessage) Fragment {
return Fragment{
Messages: messages,
Status: &Status{
PastActions: []ToolStatus{},
ReasoningLog: []string{},
ToolsCalled: Tools{},
ToolResults: []ToolStatus{},
},
}
}
// TODO: Video, Audio, Image input
type Multimedia interface {
URL() string
}
func (r Fragment) AddMessage(role, content string, mm ...Multimedia) Fragment {
chatCompletionMessage := openai.ChatCompletionMessage{
Role: role,
}
if len(mm) > 0 {
multiContent := []openai.ChatMessagePart{
{
Text: content,
Type: openai.ChatMessagePartTypeText,
},
}
for _, img := range mm {
r.Multimedia = append(r.Multimedia, img)
multiContent = append(multiContent, openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeImageURL,
ImageURL: &openai.ChatMessageImageURL{
URL: img.URL(),
},
})
}
chatCompletionMessage.MultiContent = multiContent
} else {
chatCompletionMessage.Content = content
}
r.Messages = append(r.Messages, chatCompletionMessage)
return r
}
// AddToolMessage adds a tool result message with the specified tool_call_id
func (r Fragment) AddToolMessage(content, toolCallID string) Fragment {
chatCompletionMessage := openai.ChatCompletionMessage{
Role: "tool",
Content: content,
ToolCallID: toolCallID,
}
r.Messages = append(r.Messages, chatCompletionMessage)
return r
}
func (r Fragment) AddStartMessage(role, content string, mm ...Multimedia) Fragment {
r.Messages = append([]openai.ChatCompletionMessage{
{
Role: role,
Content: content,
},
}, r.Messages...)
return r
}
// ExtractStructure extracts a structure from the result using the provided JSON schema definition
// and unmarshals it into the provided destination
func (r Fragment) ExtractStructure(ctx context.Context, llm LLM, s structures.Structure) error {
toolName := "json"
messages := slices.Clone(r.Messages)
decision := openai.ChatCompletionRequest{
Messages: messages,
Tools: []openai.Tool{
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Strict: true,
Name: toolName,
Parameters: s.Schema,
},
},
},
ToolChoice: openai.ToolChoice{
Type: openai.ToolTypeFunction,
Function: openai.ToolFunction{Name: toolName},
},
}
resp, err := llm.CreateChatCompletion(ctx, decision)
if err != nil {
return err
}
if len(resp.Choices) != 1 {
return fmt.Errorf("no choices: %d", len(resp.Choices))
}
msg := resp.Choices[0].Message
if len(msg.ToolCalls) == 0 {
return fmt.Errorf("no tool calls: %d", len(msg.ToolCalls))
}
return json.Unmarshal([]byte(msg.ToolCalls[0].Function.Arguments), s.Object)
}
type ToolChoice struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
ID string `json:"id"`
Reasoning string `json:"reasoning"`
}
// ToolCallDecision represents the decision made by a tool call callback
// It allows the callback to approve, reject, provide adjustment feedback, or directly modify the tool choice
type ToolCallDecision struct {
// Approved: true to proceed with the tool call, false to interrupt execution
Approved bool
// Adjustment: feedback string for the LLM to interpret and adjust the tool call
// Empty string means no adjustment needed. If provided, the LLM will re-evaluate
// the tool call based on this feedback.
Adjustment string
// Modified: directly modified tool choice that takes precedence over Adjustment
// If set, this tool choice is used directly without re-querying the LLM
// This allows programmatic modification of tool arguments
Modified *ToolChoice
// Skip: skip this tool call but continue execution (alternative to Approved: false)
// When true, the tool call is skipped and execution continues
Skip bool
}
// SelectTool allows the LLM to select a tool from the fragment of conversation
func (f Fragment) SelectTool(ctx context.Context, llm LLM, availableTools Tools, forceTool string) (Fragment, *ToolChoice, error) {
messages := slices.Clone(f.Messages)
decision := openai.ChatCompletionRequest{
Messages: messages,
Tools: availableTools.ToOpenAI(),
}
if forceTool != "" {
decision.ToolChoice = openai.ToolChoice{
Type: openai.ToolTypeFunction,
Function: openai.ToolFunction{Name: forceTool},
}
}
resp, err := llm.CreateChatCompletion(ctx, decision)
if err != nil {
return Fragment{}, nil, err
}
if len(resp.Choices) != 1 {
return Fragment{}, nil, fmt.Errorf("no choices: %d", len(resp.Choices))
}
if len(resp.Choices[0].Message.ToolCalls) == 0 {
xlog.Debug("LLM did not select any tool", "response", resp.Choices[0].Message)
return Fragment{}, nil, nil
}
toolCall := resp.Choices[0].Message.ToolCalls[0]
arguments := make(map[string]any)
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &arguments); err != nil {
return Fragment{}, nil, fmt.Errorf("failed to parse tool call arguments: %w", err)
}
f.Messages = append(f.Messages, openai.ChatCompletionMessage{
Role: "assistant",
ToolCalls: []openai.ToolCall{
{
Type: openai.ToolTypeFunction,
Function: openai.FunctionCall{
Name: toolCall.Function.Name,
Arguments: toolCall.Function.Arguments,
},
},
},
})
return f, &ToolChoice{Name: toolCall.Function.Name, Arguments: arguments}, nil
}
func (f Fragment) String() string {
var str strings.Builder
for _, msg := range f.Messages {
str.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content))
if len(msg.ToolCalls) > 0 {
for _, tool := range msg.ToolCalls {
str.WriteString(fmt.Sprintf(" Tool call: %s(%s)\n", tool.Function.Name, tool.Function.Arguments))
}
}
}
return str.String()
}
// AllFragmentsStrings walks through all the fragment parents to retrieve all the conversations and represent that as a string
// This is particularly useful if chaining different fragments and want to still feed the conversation
// as a context to the LLM.
func (f Fragment) AllFragmentsStrings() string {
if f.ParentFragment == nil {
return f.String()
}
return f.String() + "\n\n" + f.ParentFragment.AllFragmentsStrings()
}
func (f Fragment) AddLastMessage(f2 Fragment) Fragment {
if len(f2.Messages) > 0 {
f.Messages = append(f.Messages, f2.Messages[len(f2.Messages)-1])
}
return f
}
func (f Fragment) LastMessage() *openai.ChatCompletionMessage {
if len(f.Messages) == 0 {
return nil
}
return &f.Messages[len(f.Messages)-1]
}
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" || f.Messages[i].Role == "tool" {
found = true
lastMessages = append([]openai.ChatCompletionMessage{f.Messages[i]}, lastMessages...)
}
if found && (f.Messages[i].Role != "assistant" && f.Messages[i].Role != "tool") {
break
}
}
return lastMessages
}