feat(agent): allow empty prompt when messages exist (#179)

Andrey Nering , Arun Barua , and Ruslan Mayer created

Allow empty prompts when there are messages and no files to attach.
The last message must be a user or tool message - this ensures the
conversation state is valid for the LLM to respond.

This addresses use cases where:
- Generating from an existing conversation
- Tool results were just returned and we want the LLM to respond

Validation:
- Error if empty prompt with no messages
- Error if empty prompt with files (files need a user message)
- Error if last message is assistant (nothing to respond to)
- Allow if last message is user or tool

Closes #54
Closes #59
Closes #103

Co-authored-by: Arun Barua <arun.barua@onit.com>
Co-authored-by: Ruslan Mayer <r.stupa@ya.ru>

Change summary

agent.go      | 32 +++++++++++++++++++-
agent_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 107 insertions(+), 8 deletions(-)

Detailed changes

agent.go 🔗

@@ -11,6 +11,7 @@ import (
 	"sync"
 
 	"charm.land/fantasy/schema"
+	"github.com/charmbracelet/x/exp/slice"
 )
 
 // StepResult represents the result of a single step in an agent execution.
@@ -1019,8 +1020,33 @@ func (a *agent) validateToolCall(toolCall ToolCallContent, availableTools []Agen
 }
 
 func (a *agent) createPrompt(system, prompt string, messages []Message, files ...FilePart) (Prompt, error) {
+	// Validation: empty prompt is only allowed when there are messages,
+	// no files to attach, and the last message is a user or tool message.
 	if prompt == "" {
-		return nil, &Error{Title: "invalid argument", Message: "prompt can't be empty"}
+		lastMessage, hasMessages := slice.Last(messages)
+
+		if !hasMessages {
+			return nil, &Error{
+				Title:   "invalid argument",
+				Message: "prompt can't be empty when there are no messages",
+			}
+		}
+
+		if len(files) > 0 {
+			return nil, &Error{
+				Title:   "invalid argument",
+				Message: "prompt can't be empty when there are files",
+			}
+		}
+
+		switch lastMessage.Role {
+		case MessageRoleUser, MessageRoleTool:
+		default:
+			return nil, &Error{
+				Title:   "invalid argument",
+				Message: "prompt can't be empty when the last message is not a user or tool message",
+			}
+		}
 	}
 
 	var preparedPrompt Prompt
@@ -1029,7 +1055,9 @@ func (a *agent) createPrompt(system, prompt string, messages []Message, files ..
 		preparedPrompt = append(preparedPrompt, NewSystemMessage(system))
 	}
 	preparedPrompt = append(preparedPrompt, messages...)
-	preparedPrompt = append(preparedPrompt, NewUserMessage(prompt, files...))
+	if prompt != "" {
+		preparedPrompt = append(preparedPrompt, NewUserMessage(prompt, files...))
+	}
 	return preparedPrompt, nil
 }
 

agent_test.go 🔗

@@ -523,20 +523,91 @@ func TestAgent_Generate_BasicText(t *testing.T) {
 	require.Equal(t, int64(13), result.TotalUsage.TotalTokens)
 }
 
-// Test empty prompt error
+// Test empty prompt validation
 func TestAgent_Generate_EmptyPrompt(t *testing.T) {
 	t.Parallel()
 
 	model := &mockLanguageModel{}
 	agent := NewAgent(model)
 
-	result, err := agent.Generate(context.Background(), AgentCall{
-		Prompt: "", // Empty prompt should cause error
+	t.Run("fails without messages", func(t *testing.T) {
+		result, err := agent.Generate(context.Background(), AgentCall{
+			Prompt: "",
+		})
+		require.Error(t, err)
+		require.Nil(t, result)
+		require.Contains(t, err.Error(), "prompt can't be empty when there are no messages")
+	})
+
+	t.Run("fails with files even if messages exist", func(t *testing.T) {
+		result, err := agent.Generate(context.Background(), AgentCall{
+			Prompt: "",
+			Messages: []Message{
+				{Role: MessageRoleUser, Content: []MessagePart{TextPart{Text: "hello"}}},
+			},
+			Files: []FilePart{{Filename: "test.txt", Data: []byte("test"), MediaType: "text/plain"}},
+		})
+		require.Error(t, err)
+		require.Nil(t, result)
+		require.Contains(t, err.Error(), "prompt can't be empty when there are files")
+	})
+
+	t.Run("fails when last message is assistant", func(t *testing.T) {
+		result, err := agent.Generate(context.Background(), AgentCall{
+			Prompt: "",
+			Messages: []Message{
+				{Role: MessageRoleUser, Content: []MessagePart{TextPart{Text: "hello"}}},
+				{Role: MessageRoleAssistant, Content: []MessagePart{TextPart{Text: "hi there"}}},
+			},
+		})
+		require.Error(t, err)
+		require.Nil(t, result)
+		require.Contains(t, err.Error(), "prompt can't be empty when the last message is not a user or tool message")
+	})
+
+	t.Run("succeeds when last message is user", func(t *testing.T) {
+		model := &mockLanguageModel{
+			generateFunc: func(ctx context.Context, call Call) (*Response, error) {
+				return &Response{
+					Content:      []Content{TextContent{Text: "response"}},
+					FinishReason: FinishReasonStop,
+				}, nil
+			},
+		}
+		agent := NewAgent(model)
+
+		result, err := agent.Generate(context.Background(), AgentCall{
+			Prompt: "",
+			Messages: []Message{
+				{Role: MessageRoleUser, Content: []MessagePart{TextPart{Text: "hello"}}},
+			},
+		})
+		require.NoError(t, err)
+		require.NotNil(t, result)
 	})
 
-	require.Error(t, err)
-	require.Nil(t, result)
-	require.Contains(t, err.Error(), "invalid argument: prompt can't be empty")
+	t.Run("succeeds when last message is tool", func(t *testing.T) {
+		model := &mockLanguageModel{
+			generateFunc: func(ctx context.Context, call Call) (*Response, error) {
+				return &Response{
+					Content:      []Content{TextContent{Text: "response"}},
+					FinishReason: FinishReasonStop,
+				}, nil
+			},
+		}
+		agent := NewAgent(model)
+
+		result, err := agent.Generate(context.Background(), AgentCall{
+			Prompt: "",
+			Messages: []Message{
+				{Role: MessageRoleUser, Content: []MessagePart{TextPart{Text: "hello"}}},
+				{Role: MessageRoleAssistant, Content: []MessagePart{ToolCallPart{ToolCallID: "call_1", ToolName: "test"}}},
+				{Role: MessageRoleTool, Content: []MessagePart{ToolResultPart{ToolCallID: "call_1", Output: ToolResultOutputContentText{Text: "result"}}}},
+			},
+		})
+		require.NoError(t, err)
+		require.NotNil(t, result)
+	})
 }
 
 // Test with system prompt