diff --git a/CONVERSATIONAL_PLAN.md b/CONVERSATIONAL_PLAN.md new file mode 100644 index 0000000..aefbc14 --- /dev/null +++ b/CONVERSATIONAL_PLAN.md @@ -0,0 +1,294 @@ +# Regent Conversational Architecture Implementation Plan + +## Overview + +This document outlines the implementation plan for adding conversational capabilities to Regent, enabling stateless conversation continuation by allowing users to provide existing message history. + +## Current State Analysis + +### What We Have +- `Regent::Agent` - Creates fresh sessions for each `run()` call +- `Regent::Session` - Contains messages, spans, timing +- `Regent::Engine::React` - Already manages message history within sessions +- Clean tool and LLM abstractions + +### What We Need +- Session restoration from provided messages +- Conversation continuation without losing context +- Simple API for continuing conversations +- Backward compatibility with existing `agent.run()` API + +## Implementation Plan + +### Phase 1: Core Session Enhancements + +#### 1.1 Enhance Session Class (`lib/regent/session.rb`) + +**New Methods:** +```ruby +class Session + # Message-based initialization + def self.from_messages(messages) + + # Conversation management + def add_user_message(content) + def add_assistant_message(content) + def completed? + def last_answer + + # Export for storage + def messages_for_export +end +``` + +**Implementation Details:** +- `from_messages` - Create a new session with provided message history +- `add_user_message` - Add a new user message to the conversation +- `add_assistant_message` - Add assistant response (used by engine) +- `completed?` - Check if session has ended +- `last_answer` - Extract last assistant response from messages +- `messages_for_export` - Return messages in a format suitable for storage + +#### 1.2 Enhance Agent Class (`lib/regent/agent.rb`) + +**New Methods:** +```ruby +class Agent + # Continue conversation with existing messages + def continue(messages, new_task) + + # Modified run method to optionally return session + def run(task, return_session: false) + # Returns answer by default, or [answer, session] if requested +end +``` + +**Implementation Details:** +- `continue` - Create session from messages, add new task, run reasoning +- Modified `run` to optionally return session for continuation +- Preserve existing behavior for backward compatibility + +### Phase 2: Engine Compatibility + +#### 2.1 React Engine Updates (`lib/regent/engine/react.rb`) + +**Required Changes:** +- Accept sessions with pre-existing message history +- Ensure reasoning loop works with restored sessions +- Maintain tool execution context across conversations + +#### 2.2 Base Engine Updates (`lib/regent/engine/base.rb`) + +**Required Changes:** +- Handle sessions that start with existing messages +- Ensure LLM calls include full conversation history +- Support message continuity in span creation + +### Phase 3: API Design and Implementation + +#### 3.1 Primary Usage Patterns + +**Pattern 1: Simple Continuation** +```ruby +# Start new conversation +agent = WeatherAgent.new("You are a helpful weather assistant", model: "gpt-4o") +answer = agent.run("What's the weather in London?") +# => "It's currently 15°C and rainy in London" + +# Get session for continuation +answer, session = agent.run("What's the weather in London?", return_session: true) + +# Export messages for storage (user handles persistence) +messages = session.messages_for_export +# Store messages in your database, Redis, session, etc. + +# Later, continue the conversation with stored messages +answer = agent.continue(messages, "Is that colder than usual?") +# => "Yes, that's about 5 degrees colder than average" +``` + +**Pattern 2: Rails Controller Integration** +```ruby +class ConversationsController < ApplicationController + # POST /conversations/:id/messages + def create + # Load messages from your storage (database, Redis, etc.) + messages = load_conversation_messages(params[:id]) + + agent = WeatherAgent.new("You are a helpful weather assistant") + answer = agent.continue(messages, params[:message]) + + # Store updated messages + save_conversation_messages(params[:id], agent.session.messages_for_export) + + render json: { answer: answer } + end + + private + + def load_conversation_messages(conversation_id) + # Your implementation - could be ActiveRecord, Redis, etc. + Conversation.find(conversation_id).messages + end + + def save_conversation_messages(conversation_id, messages) + # Your implementation + Conversation.find(conversation_id).update(messages: messages) + end +end +``` + +**Pattern 3: Backward Compatibility** +```ruby +# Existing API continues to work unchanged +agent = WeatherAgent.new("You are a weather assistant", model: "gpt-4o") +answer = agent.run("What's the weather?") +# => Works exactly as before, returns just the answer + +# Access session after run (existing behavior) +agent.session.messages +# => Array of messages from the conversation +``` + +#### 3.2 Message Format + +**Standard Message Structure:** +```ruby +# Messages should follow this format +messages = [ + { role: "user", content: "What's the weather?" }, + { role: "assistant", content: "It's sunny and 22°C" }, + { role: "user", content: "Should I bring an umbrella?" }, + { role: "assistant", content: "No need for an umbrella today!" } +] + +# The agent handles converting these to internal Message objects +``` + +**Session Export Format:** +```ruby +# session.messages_for_export returns a simple array +exported = session.messages_for_export +# => [ +# { role: "user", content: "...", timestamp: "2024-01-15T10:30:00Z" }, +# { role: "assistant", content: "...", timestamp: "2024-01-15T10:30:05Z" } +# ] +``` + +### Phase 4: Testing Strategy + +#### 4.1 Unit Tests +- Session creation from messages (`Session.from_messages`) +- Message addition and export functionality +- Agent continuation methods +- Message format validation + +#### 4.2 Integration Tests +- Full conversation flows with message passing +- Error handling (invalid messages, malformed data) +- Context preservation across continuations +- Tool state handling in continued conversations + +#### 4.3 Backward Compatibility Tests +- Ensure existing `agent.run()` behavior unchanged +- Verify all existing specs continue to pass +- Test that new parameters don't break existing usage + +### Phase 5: Documentation and Examples + +#### 5.1 README Updates +- Add conversational usage examples +- Document message format requirements +- Show different storage strategies (Redis, database, etc.) + +#### 5.2 Example Applications +- Simple conversation with in-memory storage +- Rails API with database-backed conversations +- Stateless API with client-side message storage + +## Implementation Order + +### Sprint 1: Core Infrastructure +1. Add `Session.from_messages` method +2. Implement `Agent#continue` method +3. Add message export functionality to Session +4. Write comprehensive tests + +### Sprint 2: Engine Updates +1. Update engines for message history support +2. Ensure proper context handling +3. Test conversation continuity +4. Integration testing + +### Sprint 3: Polish and Documentation +1. Backward compatibility verification +2. Performance testing with large message histories +3. Documentation updates +4. Example applications + +## Technical Considerations + +### Performance +- Message array processing should be efficient +- Consider limiting message history size +- Minimize memory usage for large conversations +- Only include necessary message data in exports + +### Security +- Validate message format and content +- Ensure no code injection through message content +- Users responsible for securing their own message storage + +### Error Handling +- Validate message structure on import +- Handle missing or malformed message data gracefully +- Clear error messages for invalid formats + +### Scalability +- Stateless design enables horizontal scaling +- Message storage strategy determined by user +- No built-in persistence overhead + +## Migration Strategy + +### For Existing Users +1. All existing code continues to work unchanged +2. Opt-in to conversational features via new methods +3. No database requirements or migrations needed +4. Simple upgrade path with examples + +## Success Metrics + +### Functional Requirements +- [ ] Can continue conversations using provided messages +- [ ] Message history properly restored in sessions +- [ ] Context preserved across continuations +- [ ] Simple API for message export/import +- [ ] Backward compatibility maintained + +### Non-Functional Requirements +- [ ] Performance comparable to current single-run behavior +- [ ] Minimal memory overhead for message handling +- [ ] Clear, simple API that follows Ruby conventions +- [ ] Clear error messages for invalid inputs + +## Future Enhancements + +### Potential Extensions +- Message compression for large histories +- Automatic message pruning strategies +- Conversation branching support +- Streaming conversation updates +- Multi-agent conversation coordination +- Standard export formats (OpenAI, Anthropic, etc.) + +## Summary + +This simplified approach removes all database and persistence requirements, making Regent conversations completely stateless. Users provide their existing messages when continuing conversations, and the Agent class handles creating sessions with the proper context. This design: + +1. **Simplifies the API** - No database migrations or ActiveRecord models needed +2. **Increases flexibility** - Users can store messages anywhere (database, Redis, sessions, client-side) +3. **Maintains simplicity** - Follows Regent's philosophy of elegant, simple abstractions +4. **Ensures compatibility** - Existing code continues to work without changes + +The implementation focuses on enhancing the Session and Agent classes to accept and work with provided message histories, making conversational AI accessible without infrastructure overhead. \ No newline at end of file diff --git a/README.md b/README.md index dbd5ef9..3b481c1 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,135 @@ Outputs: [✔] [ANSWER ❯ success][0.03s]: It is 70 degrees and sunny in San Francisco. ``` +### Conversations + +Regent supports stateless conversation continuation, allowing you to maintain context across multiple interactions without built-in persistence. You manage message storage however you prefer (database, Redis, session storage, etc.). + +The `run` method handles both new conversations and continuations seamlessly: +- `agent.run(task)` - Start a new conversation +- `agent.run(task, messages: stored_messages)` - Continue from stored messages +- `agent.run(task)` - Continue current conversation (after loading messages) + +#### Basic Conversation Flow + +```ruby +class WeatherAgent < Regent::Agent + tool(:current_weather_tool, "Get current weather for a location") + tool(:historical_weather_tool, "Get the historical average weather for a location on a given date") + + def current_weather_tool(location) + case location + when "London" + "Currently 72°F and sunny in #{location}" + when "New York" + "Currently 80°F and overcast in #{location}" + end + end + + def historical_weather_tool(location, date) + case location + when "London" + "Usually it's 68°F and overcast in #{location} on #{date}" + when "New York" + "Usually it's 88°F and sunny in #{location} on #{date}" + end + end +end + +# Start a new conversation +agent = WeatherAgent.new("You are a helpful weather assistant. It is #{Date.today}.", model: "gpt-4o") +answer, session = agent.run("What's the weather in London?", return_session: true) +# => "The current weather in London is 72°F and sunny." + +# Export messages for storage +messages = session.messages_for_export +# Store messages in your preferred storage (database, Redis, etc.) +``` + +#### Continuing a Conversation + +```ruby +# Later, load messages from your storage and continue +stored_messages = load_from_storage() # Your implementation should return: +# [ +# { role: :system, content: "You are a helpful weather assistant" }, +# { role: :user, content: "What's the weather in London?" }, +# { role: :assistant, content: "It's currently 15°C and rainy in London" } +# ] + +# Continue the conversation +agent = WeatherAgent.new("You are a helpful weather assistant. It is #{Date.today}.", model: "gpt-4o") +answer1 = agent.run("Is that colder than usual?", messages: stored_messages) +# => "Actually, it's warmer than usual! The current temperature in London is 72°F and sunny, +# while historically it's usually 68°F and overcast on June 20th. So it's 4 degrees +# warmer and sunnier than typical." + +# Continue the same conversation without passing messages again +answer2 = agent.run("Thanks. What's the weather in New York? How does it compare to London?") +# => "The current weather in New York is 80°F and overcast. Comparing the two cities: +# +# - **Temperature**: New York is warmer at 80°F compared to London's 72°F (8 degrees warmer) +# - **Conditions**: New York is overcast while London is sunny today +# +# So New York is experiencing warmer but cloudier weather compared to London's cooler but +# sunnier conditions." +``` + +#### Rails Integration Example + +```ruby +class ConversationsController < ApplicationController + def create + # Load existing messages if conversation exists + messages = conversation_params[:id] ? load_messages(conversation_params[:id]) : [] + + # Create agent and process message + agent = ChatAgent.new("You are a helpful assistant", model: "gpt-4o") + + answer, session = agent.run(params[:message], messages: messages, return_session: true) + + save_messages(conversation_params[:id], session.messages_for_export) + + render json: { answer: answer, conversation_id: conversation_params[:id] } + end + + private + + def load_messages(conversation_id) + # Your storage implementation - could be ActiveRecord, Redis, etc. + Conversation.find(conversation_id).messages.map { |m| + { role: m["role"].to_sym, content: m["content"] } + } + end + + def save_messages(conversation_id, messages) + Conversation.find(conversation_id).update(messages: messages) + end +end +``` + +#### Message Format + +Messages follow a simple format with role and content: + +```ruby +messages = [ + { role: :system, content: "You are a helpful assistant" }, + { role: :user, content: "Hello!" }, + { role: :assistant, content: "Hi! How can I help you today?" } +] +``` + +The `messages_for_export` method returns messages with timestamps for storage: + +```ruby +exported = session.messages_for_export +# => [ +# { role: "user", content: "Hello!", timestamp: "2024-01-15T10:30:00Z" }, +# { role: "assistant", content: "Hi! How can I help you today?", timestamp: "2024-01-15T10:30:05Z" } +# ] +``` + ### Engine By default, Regent uses ReAct agent architecture. You can see the [details of its implementation](https://github.com/alchaplinsky/regent/blob/main/lib/regent/engine/react.rb). However, Agent constructor accepts an `engine` option that allows you to swap agent engine when instantiating an Agent. This way you can implement your own agent architecture that can be plugged in and user within Regent framework. diff --git a/lib/regent/agent.rb b/lib/regent/agent.rb index 32de0a2..8b9658d 100644 --- a/lib/regent/agent.rb +++ b/lib/regent/agent.rb @@ -16,17 +16,47 @@ def initialize(context, model:, tools: [], engine: Regent::Engine::React, **opti @sessions = [] @tools = build_toolchain(tools) @max_iterations = options[:max_iterations] || DEFAULT_MAX_ITERATIONS + @continuing_session = false end attr_reader :context, :sessions, :model, :tools, :inline_tools - def run(task) + # Runs an agent task or continues a conversation + # @param task [String] The task or message to process + # @param messages [Array, nil] Optional message history to continue from + # @param return_session [Boolean] Whether to return both result and session + # @return [String, Array] The result or [result, session] if return_session is true + def run(task, messages: nil, return_session: false) raise ArgumentError, "Task cannot be empty" if task.to_s.strip.empty? - start_session - reason(task) + if !messages.nil? && messages.any? + # Continue from provided message history + @sessions << Session.from_messages(messages) + session.reactivate + @continuing_session = true + elsif @continuing_session + # Continue current conversation + raise ArgumentError, "No active conversation to continue" unless session + session.reactivate if session.completed? + else + # Start new session + start_session + end + + result = reason(task) + return_session ? [result, session] : result ensure - complete_session + complete_session unless @continuing_session + end + + # Legacy method for backward compatibility + # @deprecated Use {#run} with messages parameter instead + def continue(messages_or_task, new_task = nil) + if messages_or_task.is_a?(String) + run(messages_or_task) + else + run(new_task, messages: messages_or_task) + end end def running? @@ -47,6 +77,7 @@ def start_session complete_session @sessions << Session.new session.start + @continuing_session = false end def complete_session diff --git a/lib/regent/engine/react.rb b/lib/regent/engine/react.rb index a864893..32aa7cc 100644 --- a/lib/regent/engine/react.rb +++ b/lib/regent/engine/react.rb @@ -12,8 +12,16 @@ class React < Base def reason(task) session.exec(Span::Type::INPUT, top_level: true, message: task) { task } - session.add_message({role: :system, content: Regent::Engine::React::PromptTemplate.system_prompt(context, toolchain.to_s)}) - session.add_message({role: :user, content: task}) + + # Only add system prompt if this is a new conversation + if session.messages.empty? || session.messages.none? { |msg| msg[:role] == :system } + session.add_message({role: :system, content: Regent::Engine::React::PromptTemplate.system_prompt(context, toolchain.to_s)}) + end + + # Only add user message if it's not already the last message (from continue method) + unless session.messages.last && session.messages.last[:role] == :user && session.messages.last[:content] == task + session.add_message({role: :user, content: task}) + end with_max_iterations do content = llm_call_response(stop: [SEQUENCES[:stop]]) diff --git a/lib/regent/session.rb b/lib/regent/session.rb index 70a0d7f..9d9a1a4 100644 --- a/lib/regent/session.rb +++ b/lib/regent/session.rb @@ -18,6 +18,32 @@ def initialize @end_time = nil end + # Creates a new session from existing messages + # @param messages [Array] Array of message hashes with :role and :content keys + # @return [Session] A new session with the provided message history + def self.from_messages(messages) + session = new + messages.each do |msg| + session.add_message(msg) + end + session + end + + # Validates message format + # @param message [Hash] The message to validate + # @raise [ArgumentError] if message format is invalid + def self.validate_message_format(message) + raise ArgumentError, "Message must be a Hash" unless message.is_a?(Hash) + raise ArgumentError, "Message must have :role key" unless message.key?(:role) || message.key?("role") + raise ArgumentError, "Message must have :content key" unless message.key?(:content) || message.key?("content") + + role = (message[:role] || message["role"]).to_sym + content = message[:content] || message["content"] + + raise ArgumentError, "Message role must be :user, :assistant, or :system" unless [:user, :assistant, :system].include?(role) + raise ArgumentError, "Message content cannot be empty" if content.to_s.strip.empty? + end + attr_reader :id, :spans, :messages, :start_time, :end_time # Starts the session @@ -75,12 +101,78 @@ def active? end # Adds a message to the session - # @param message [String] The message to add - # @raise [ArgumentError] if message is nil or empty + # @param message [Hash] The message to add with :role and :content keys + # @raise [ArgumentError] if message is nil or invalid format def add_message(message) - raise ArgumentError, "Message cannot be nil or empty" if message.nil? || message.empty? + raise ArgumentError, "Message cannot be nil" if message.nil? + self.class.validate_message_format(message) + + role = (message[:role] || message["role"]).to_sym + content = message[:content] || message["content"] + @messages << { role: role, content: content } + end - @messages << message + # Adds a user message to the conversation + # @param content [String] The message content + # @return [void] + def add_user_message(content) + add_message({ role: :user, content: content }) + end + + # Adds an assistant message to the conversation + # @param content [String] The message content + # @return [void] + def add_assistant_message(content) + add_message({ role: :assistant, content: content }) + end + + # Checks if the session has been completed + # @return [Boolean] true if session has ended + def completed? + !end_time.nil? + end + + # Retrieves the last assistant answer from messages or spans + # @return [String, nil] The last assistant response or nil if none found + def last_answer + # First check messages for assistant responses + last_assistant_msg = messages.reverse.find { |msg| msg[:role] == :assistant } + return last_assistant_msg[:content] if last_assistant_msg + + # Fallback to spans with answer type + answer_span = spans.reverse.find { |span| span.type == Span::Type::ANSWER } + answer_span&.output + end + + # Exports messages in a format suitable for storage + # @return [Array] Array of message hashes with role, content, and timestamp + def messages_for_export + messages.map.with_index do |msg, index| + # Calculate approximate timestamp based on session timing and message position + timestamp = if start_time + if messages.length > 1 + duration = (end_time || Time.now) - start_time + start_time + (duration * index.to_f / (messages.length - 1)) + else + start_time + end + else + Time.now + end + + { + role: msg[:role].to_s, + content: msg[:content], + timestamp: timestamp.iso8601 + } + end + end + + # Reactivates a completed session for continuation + # @return [void] + def reactivate + @end_time = nil + @start_time ||= Time.now.freeze end end end diff --git a/spec/regent/conversational_spec.rb b/spec/regent/conversational_spec.rb new file mode 100644 index 0000000..0f48426 --- /dev/null +++ b/spec/regent/conversational_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +RSpec.describe "Conversational Features" do + describe Regent::Session do + describe ".from_messages" do + it "creates a session with provided message history" do + messages = [ + { role: :user, content: "Hello" }, + { role: :assistant, content: "Hi there!" }, + { role: :user, content: "How are you?" } + ] + + session = Regent::Session.from_messages(messages) + + expect(session.messages).to eq(messages) + expect(session.messages.count).to eq(3) + end + + it "validates message format" do + expect { + Regent::Session.from_messages([{ role: :user }]) + }.to raise_error(ArgumentError, "Message must have :content key") + + expect { + Regent::Session.from_messages([{ content: "Hello" }]) + }.to raise_error(ArgumentError, "Message must have :role key") + + expect { + Regent::Session.from_messages([{ role: :invalid, content: "Hello" }]) + }.to raise_error(ArgumentError, "Message role must be :user, :assistant, or :system") + end + end + + describe "#messages_for_export" do + it "exports messages with timestamps" do + session = Regent::Session.new + session.start + session.add_user_message("Hello") + session.add_assistant_message("Hi there!") + + exported = session.messages_for_export + + # Just check structure, not exact timestamps since they're dynamic + expect(exported).to match([ + hash_including( + role: "user", + content: "Hello", + timestamp: match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + ), + hash_including( + role: "assistant", + content: "Hi there!", + timestamp: match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + ) + ]) + end + end + + describe "#add_user_message and #add_assistant_message" do + it "adds messages with correct roles" do + session = Regent::Session.new + + session.add_user_message("User question") + session.add_assistant_message("Assistant response") + + expect(session.messages[0]).to eq({ role: :user, content: "User question" }) + expect(session.messages[1]).to eq({ role: :assistant, content: "Assistant response" }) + end + end + + describe "#last_answer" do + it "returns the last assistant message" do + session = Regent::Session.new + session.add_user_message("Question 1") + session.add_assistant_message("Answer 1") + session.add_user_message("Question 2") + session.add_assistant_message("Answer 2") + + expect(session.last_answer).to eq("Answer 2") + end + + it "returns nil if no assistant messages" do + session = Regent::Session.new + session.add_user_message("Question") + + expect(session.last_answer).to be_nil + end + end + + describe "#completed? and #reactivate" do + it "tracks session completion state" do + session = Regent::Session.new + session.start + + expect(session.completed?).to be false + + session.complete + expect(session.completed?).to be true + + session.reactivate + expect(session.completed?).to be false + expect(session.active?).to be true + end + end + end + + describe Regent::Agent do + let(:llm_result) { double("result", content: "Answer: The answer", input_tokens: 10, output_tokens: 20) } + let(:llm) { double("llm", model: "gpt-4o-mini", invoke: llm_result) } + let(:agent) { Regent::Agent.new("You are a helpful assistant", model: llm) } + + describe "#run with return_session option" do + it "returns just the answer by default" do + result = agent.run("Question") + expect(result).to eq("The answer") + end + + it "returns both answer and session when requested" do + answer, session = agent.run("Question", return_session: true) + + expect(answer).to eq("The answer") + expect(session).to be_a(Regent::Session) + expect(session.messages).to include(hash_including(role: :user, content: "Question")) + end + end + + describe "#run with messages" do + it "continues a conversation with existing messages" do + messages = [ + { role: :system, content: "You are a helpful assistant" }, + { role: :user, content: "What's 2+2?" }, + { role: :assistant, content: "2+2 equals 4" } + ] + + llm_result2 = double("result", content: "Answer: Yes, that's correct!", input_tokens: 10, output_tokens: 20) + allow(llm).to receive(:invoke).and_return(llm_result2) + + answer = agent.run("Is that right?", messages: messages) + + expect(answer).to eq("Yes, that's correct!") + expect(agent.session.messages.count).to be >= 4 # Original 3 + new user message + any system prompts + expect(agent.session.messages.last).to eq({ role: :assistant, content: "Answer: Yes, that's correct!" }) + end + + it "allows continuing the same conversation without re-passing messages" do + messages = [ + { role: :system, content: "You are a helpful assistant" }, + { role: :user, content: "What's 2+2?" }, + { role: :assistant, content: "2+2 equals 4" } + ] + + llm_result2 = double("result", content: "Answer: Yes, that's correct!", input_tokens: 10, output_tokens: 20) + llm_result3 = double("result", content: "Answer: 3+3 equals 6", input_tokens: 10, output_tokens: 20) + allow(llm).to receive(:invoke).and_return(llm_result2, llm_result3) + + # First run with messages + answer1 = agent.run("Is that right?", messages: messages) + expect(answer1).to eq("Yes, that's correct!") + + # Second run continues the conversation + answer2 = agent.run("What's 3+3?") + expect(answer2).to eq("3+3 equals 6") + + # Check that messages accumulated properly + expect(agent.session.messages.any? { |m| m[:content] == "What's 3+3?" }).to be true + end + + it "validates inputs" do + expect { + agent.run("Question", messages: []) + }.to raise_error(ArgumentError, "Messages cannot be empty") + + expect { + agent.run("", messages: [{ role: :user, content: "Hi" }]) + }.to raise_error(ArgumentError, "Task cannot be empty") + end + end + + describe "#continue (legacy method)" do + it "delegates to run with messages" do + messages = [{ role: :user, content: "Hi" }] + expect(agent).to receive(:run).with("Hello", messages: messages) + agent.continue(messages, "Hello") + end + + it "delegates to run for string continuation" do + # Set up an active conversation first + agent.instance_variable_set(:@continuing_session, true) + agent.instance_variable_set(:@sessions, [double("session", completed?: false)]) + + expect(agent).to receive(:run).with("Hello") + agent.continue("Hello") + end + end + end + + describe "End-to-end conversation flow" do + let(:llm_result1) { double("result", content: "Answer: 2+2 equals 4", input_tokens: 10, output_tokens: 20) } + let(:llm_result2) { double("result", content: "Answer: Great job! 3+3 equals 6", input_tokens: 10, output_tokens: 20) } + let(:llm) { double("llm", model: "gpt-4o-mini") } + let(:agent) { Regent::Agent.new("You are a helpful math tutor", model: llm) } + + it "maintains conversation context across multiple interactions" do + # First interaction + allow(llm).to receive(:invoke).and_return(llm_result1) + answer1, session1 = agent.run("What's 2+2?", return_session: true) + + expect(answer1).to include("4") + + # Export messages - they should have string roles + messages = session1.messages_for_export + expect(messages.first[:role]).to be_a(String) + + # Continue conversation - strip timestamps and convert roles to symbols + allow(llm).to receive(:invoke).and_return(llm_result2) + answer2 = agent.run("Good! Now what's 3+3?", messages: messages.map { |m| { role: m[:role].to_sym, content: m[:content] } }) + + expect(answer2).to include("6") + expect(agent.session.messages.any? { |m| m[:content].include?("2+2") }).to be true + end + end +end