From 394b812300851f001a614d822007b37bcde0895c Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 10 Jun 2025 15:04:56 +1000 Subject: [PATCH 01/10] Claude plan --- CONVERSATIONAL_PLAN.md | 342 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 CONVERSATIONAL_PLAN.md diff --git a/CONVERSATIONAL_PLAN.md b/CONVERSATIONAL_PLAN.md new file mode 100644 index 0000000..6b98743 --- /dev/null +++ b/CONVERSATIONAL_PLAN.md @@ -0,0 +1,342 @@ +# Regent Conversational Architecture Implementation Plan + +## Overview + +This document outlines the implementation plan for adding conversational capabilities to Regent, enabling persistent conversations across requests in Rails applications. + +## Current State Analysis + +### What We Have +- `Regent::Agent` - Creates fresh sessions for each `run()` call +- `Regent::Session` - Contains messages, spans, timing (perfect for persistence) +- `Regent::Engine::React` - Already manages message history within sessions +- Clean tool and LLM abstractions + +### What We Need +- Session persistence and restoration +- Conversation continuation without losing context +- Rails-friendly API for new vs 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 + # Persistence + def to_h + def self.from_h(hash) + + # Conversation management + def continue(task) + def reactivate + def completed? + def last_answer + + # Rails integration + def associate_conversation(conversation_record) + def persist! +end +``` + +**Implementation Details:** +- `to_h` - Serialize session state including messages, spans, metadata +- `from_h` - Reconstruct session from persisted hash data +- `continue(task)` - Add new user message and reactivate if completed +- `reactivate` - Reset `@end_time` to nil to make session active again +- `last_answer` - Extract last assistant response from messages or answer span +- Rails integration methods for ActiveRecord association + +#### 1.2 Enhance Agent Class (`lib/regent/agent.rb`) + +**New Methods:** +```ruby +class Agent + # Class methods for conversation management + def self.start_conversation(context, user: nil, **options) + + # Instance methods for session continuation + def continue_session(session, task) + + # Modified run method to return both answer and session + def run(task) + # Returns [answer, session] instead of just answer +end +``` + +**Implementation Details:** +- `start_conversation` - Create new persisted conversation record +- `continue_session` - Reactivate session, add message, run reasoning +- Modified `run` to optionally return session for immediate continuation +- Preserve existing behavior for backward compatibility + +### Phase 2: Rails Integration Layer + +#### 2.1 ActiveRecord Model + +**Migration:** +```ruby +class CreateRegentConversations < ActiveRecord::Migration[7.0] + def change + create_table :regent_conversations do |t| + t.string :agent_class, null: false # "WeatherAgent" + t.text :context, null: false # Agent's system context + t.json :agent_config, default: {} # model, tools, options + t.json :messages, default: [] # Full conversation history + t.json :spans, default: [] # Execution traces + t.references :user, foreign_key: true, null: true + t.string :title # Optional conversation title + t.timestamps + end + + add_index :regent_conversations, [:user_id, :created_at] + add_index :regent_conversations, :agent_class + end +end +``` + +**Model Implementation:** +```ruby +class RegentConversation < ApplicationRecord + belongs_to :user, optional: true + + validates :agent_class, :context, presence: true + validate :agent_class_exists + + scope :by_agent, ->(agent_class) { where(agent_class: agent_class.name) } + scope :recent, -> { order(updated_at: :desc) } + + # Core conversation methods + def ask(question) + def agent_instance + def to_regent_session + + # Utility methods + def message_count + def tokens_used + def last_answer + + private + + def agent_class_exists + agent_class.constantize + rescue NameError + errors.add(:agent_class, "is not a valid agent class") + end +end +``` + +#### 2.2 Engine Compatibility + +**React Engine Updates (`lib/regent/engine/react.rb`):** +- Ensure message history preservation across session continuations +- Handle session reactivation in reasoning loop +- Maintain tool execution context across conversations + +**Base Engine Updates (`lib/regent/engine/base.rb`):** +- Update span creation to handle session restoration +- Ensure LLM calls work with restored message history + +### Phase 3: API Design and Implementation + +#### 3.1 Primary Usage Patterns + +**Pattern 1: Rails Controller Integration** +```ruby +class ConversationsController < ApplicationController + # POST /conversations + def create + @conversation = WeatherAgent.start_conversation( + "You are a helpful weather assistant", + user: current_user, + model: params[:model] || "gpt-4o" + ) + + if params[:message].present? + answer = @conversation.ask(params[:message]) + render json: { answer: answer, conversation_id: @conversation.id } + else + render json: { conversation_id: @conversation.id } + end + end + + # POST /conversations/:id/messages + def message + @conversation = current_user.regent_conversations.find(params[:id]) + answer = @conversation.ask(params[:message]) + + render json: { + answer: answer, + conversation_id: @conversation.id, + message_count: @conversation.message_count + } + end +end +``` + +**Pattern 2: Direct Ruby Usage** +```ruby +# Start new conversation +conversation = WeatherAgent.start_conversation( + "You are a weather assistant", + user: current_user, + model: "gpt-4o" +) + +answer = conversation.ask("What's the weather in London?") +# => "It's currently 15°C and rainy in London" + +# Continue conversation in another request +conversation = RegentConversation.find(123) +answer = conversation.ask("Is that colder than usual?") +# => "Yes, that's about 5 degrees colder than average" +``` + +**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 + +# New session-aware API +answer, session = agent.run("What's the weather?") +next_answer = agent.continue_session(session, "Is it going to rain?") +``` + +#### 3.2 Advanced Usage Patterns + +**Conversation Management:** +```ruby +# List user's conversations +user.regent_conversations.by_agent(WeatherAgent).recent + +# Conversation metadata +conversation.message_count # => 5 +conversation.tokens_used # => 1247 +conversation.last_answer # => "Yes, that's colder than usual" + +# Export conversation +conversation.to_h # Full conversation export + +# Clone conversation with new context +new_conversation = conversation.clone_with_context("You are now a travel assistant") +``` + +### Phase 4: Testing Strategy + +#### 4.1 Unit Tests +- Session serialization/deserialization (`to_h`/`from_h`) +- Session continuation and reactivation +- Agent conversation management +- ActiveRecord model validations and associations + +#### 4.2 Integration Tests +- Full conversation flows across multiple requests +- Rails controller integration +- Error handling (invalid sessions, missing conversations) +- User scoping and permissions + +#### 4.3 Backward Compatibility Tests +- Ensure existing `agent.run()` behavior unchanged +- Verify all existing specs continue to pass +- Test migration paths for existing code + +### Phase 5: Documentation and Examples + +#### 5.1 README Updates +- Add conversational usage examples +- Document Rails integration patterns +- Show migration from single-run to conversational usage + +#### 5.2 Example Applications +- Rails API example with conversation management +- Background job integration for long-running conversations +- Multi-user conversation scenarios + +## Implementation Order + +### Sprint 1: Core Infrastructure +1. Enhance `Session` class with persistence methods +2. Add `Agent` conversation management methods +3. Create basic ActiveRecord model +4. Write comprehensive tests + +### Sprint 2: Rails Integration +1. Complete ActiveRecord model with all features +2. Update engines for conversation compatibility +3. Add controller patterns and examples +4. Integration testing + +### Sprint 3: Polish and Documentation +1. Backward compatibility verification +2. Performance optimization +3. Documentation updates +4. Example applications + +## Technical Considerations + +### Performance +- JSON serialization for messages/spans should be efficient +- Index conversations by user and recency +- Consider pagination for long conversations +- Lazy loading of spans for large conversation histories + +### Security +- Ensure user can only access their own conversations +- Validate agent class names to prevent code injection +- Sanitize conversation data before persistence + +### Error Handling +- Graceful handling of corrupted session data +- Fallback behavior when session restoration fails +- Clear error messages for invalid conversation states + +### Scalability +- Design for horizontal scaling (stateless requests) +- Consider separate storage for large conversation histories +- Plan for conversation archival and cleanup + +## Migration Strategy + +### For Existing Users +1. All existing code continues to work unchanged +2. Opt-in to conversational features via new methods +3. Gradual migration path with examples and guides +4. No breaking changes to current API + +### Database Migrations +1. Add `regent_conversations` table +2. Optional: Add indexes for performance +3. Provide generator for Rails applications + +## Success Metrics + +### Functional Requirements +- [ ] Can start new conversations and persist them +- [ ] Can continue conversations across requests +- [ ] Full message history preserved +- [ ] Execution traces (spans) available for debugging +- [ ] Backward compatibility maintained + +### Non-Functional Requirements +- [ ] Performance comparable to current single-run behavior +- [ ] Memory usage doesn't grow with conversation length +- [ ] Rails integration feels natural and idiomatic +- [ ] Clear error messages and debugging capabilities + +## Future Enhancements + +### Phase 6+: Advanced Features +- Conversation branching and forking +- Conversation templates and presets +- Real-time conversation streaming +- Conversation analytics and insights +- Multi-agent conversations +- Conversation export/import formats + +This plan provides a comprehensive roadmap for implementing conversational capabilities while maintaining Regent's elegant simplicity and Ruby idioms. \ No newline at end of file From 70c6599d5a7fa732dbd70183531d2ea8fbeb9ca1 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Jun 2025 10:41:07 +1000 Subject: [PATCH 02/10] plan changes --- CONVERSATIONAL_PLAN.md | 366 ++++++++++++++++++----------------------- 1 file changed, 159 insertions(+), 207 deletions(-) diff --git a/CONVERSATIONAL_PLAN.md b/CONVERSATIONAL_PLAN.md index 6b98743..aefbc14 100644 --- a/CONVERSATIONAL_PLAN.md +++ b/CONVERSATIONAL_PLAN.md @@ -2,20 +2,20 @@ ## Overview -This document outlines the implementation plan for adding conversational capabilities to Regent, enabling persistent conversations across requests in Rails applications. +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 (perfect for persistence) +- `Regent::Session` - Contains messages, spans, timing - `Regent::Engine::React` - Already manages message history within sessions - Clean tool and LLM abstractions ### What We Need -- Session persistence and restoration +- Session restoration from provided messages - Conversation continuation without losing context -- Rails-friendly API for new vs continuing conversations +- Simple API for continuing conversations - Backward compatibility with existing `agent.run()` API ## Implementation Plan @@ -27,316 +27,268 @@ This document outlines the implementation plan for adding conversational capabil **New Methods:** ```ruby class Session - # Persistence - def to_h - def self.from_h(hash) + # Message-based initialization + def self.from_messages(messages) # Conversation management - def continue(task) - def reactivate + def add_user_message(content) + def add_assistant_message(content) def completed? def last_answer - # Rails integration - def associate_conversation(conversation_record) - def persist! + # Export for storage + def messages_for_export end ``` **Implementation Details:** -- `to_h` - Serialize session state including messages, spans, metadata -- `from_h` - Reconstruct session from persisted hash data -- `continue(task)` - Add new user message and reactivate if completed -- `reactivate` - Reset `@end_time` to nil to make session active again -- `last_answer` - Extract last assistant response from messages or answer span -- Rails integration methods for ActiveRecord association +- `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 - # Class methods for conversation management - def self.start_conversation(context, user: nil, **options) + # Continue conversation with existing messages + def continue(messages, new_task) - # Instance methods for session continuation - def continue_session(session, task) - - # Modified run method to return both answer and session - def run(task) - # Returns [answer, session] instead of just answer + # 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:** -- `start_conversation` - Create new persisted conversation record -- `continue_session` - Reactivate session, add message, run reasoning -- Modified `run` to optionally return session for immediate continuation +- `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: Rails Integration Layer - -#### 2.1 ActiveRecord Model - -**Migration:** -```ruby -class CreateRegentConversations < ActiveRecord::Migration[7.0] - def change - create_table :regent_conversations do |t| - t.string :agent_class, null: false # "WeatherAgent" - t.text :context, null: false # Agent's system context - t.json :agent_config, default: {} # model, tools, options - t.json :messages, default: [] # Full conversation history - t.json :spans, default: [] # Execution traces - t.references :user, foreign_key: true, null: true - t.string :title # Optional conversation title - t.timestamps - end - - add_index :regent_conversations, [:user_id, :created_at] - add_index :regent_conversations, :agent_class - end -end -``` - -**Model Implementation:** -```ruby -class RegentConversation < ApplicationRecord - belongs_to :user, optional: true - - validates :agent_class, :context, presence: true - validate :agent_class_exists - - scope :by_agent, ->(agent_class) { where(agent_class: agent_class.name) } - scope :recent, -> { order(updated_at: :desc) } - - # Core conversation methods - def ask(question) - def agent_instance - def to_regent_session - - # Utility methods - def message_count - def tokens_used - def last_answer - - private - - def agent_class_exists - agent_class.constantize - rescue NameError - errors.add(:agent_class, "is not a valid agent class") - end -end -``` +### Phase 2: Engine Compatibility -#### 2.2 Engine Compatibility +#### 2.1 React Engine Updates (`lib/regent/engine/react.rb`) -**React Engine Updates (`lib/regent/engine/react.rb`):** -- Ensure message history preservation across session continuations -- Handle session reactivation in reasoning loop +**Required Changes:** +- Accept sessions with pre-existing message history +- Ensure reasoning loop works with restored sessions - Maintain tool execution context across conversations -**Base Engine Updates (`lib/regent/engine/base.rb`):** -- Update span creation to handle session restoration -- Ensure LLM calls work with restored message history +#### 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: Rails Controller Integration** +**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 + # POST /conversations/:id/messages def create - @conversation = WeatherAgent.start_conversation( - "You are a helpful weather assistant", - user: current_user, - model: params[:model] || "gpt-4o" - ) + # Load messages from your storage (database, Redis, etc.) + messages = load_conversation_messages(params[:id]) - if params[:message].present? - answer = @conversation.ask(params[:message]) - render json: { answer: answer, conversation_id: @conversation.id } - else - render json: { conversation_id: @conversation.id } - end + 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 - # POST /conversations/:id/messages - def message - @conversation = current_user.regent_conversations.find(params[:id]) - answer = @conversation.ask(params[:message]) - - render json: { - answer: answer, - conversation_id: @conversation.id, - message_count: @conversation.message_count - } + 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 2: Direct Ruby Usage** -```ruby -# Start new conversation -conversation = WeatherAgent.start_conversation( - "You are a weather assistant", - user: current_user, - model: "gpt-4o" -) - -answer = conversation.ask("What's the weather in London?") -# => "It's currently 15°C and rainy in London" - -# Continue conversation in another request -conversation = RegentConversation.find(123) -answer = conversation.ask("Is that colder than usual?") -# => "Yes, that's about 5 degrees colder than average" -``` - **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 +# => Works exactly as before, returns just the answer -# New session-aware API -answer, session = agent.run("What's the weather?") -next_answer = agent.continue_session(session, "Is it going to rain?") +# Access session after run (existing behavior) +agent.session.messages +# => Array of messages from the conversation ``` -#### 3.2 Advanced Usage Patterns +#### 3.2 Message Format -**Conversation Management:** +**Standard Message Structure:** ```ruby -# List user's conversations -user.regent_conversations.by_agent(WeatherAgent).recent - -# Conversation metadata -conversation.message_count # => 5 -conversation.tokens_used # => 1247 -conversation.last_answer # => "Yes, that's colder than usual" - -# Export conversation -conversation.to_h # Full conversation export +# 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 +``` -# Clone conversation with new context -new_conversation = conversation.clone_with_context("You are now a travel assistant") +**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 serialization/deserialization (`to_h`/`from_h`) -- Session continuation and reactivation -- Agent conversation management -- ActiveRecord model validations and associations +- 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 across multiple requests -- Rails controller integration -- Error handling (invalid sessions, missing conversations) -- User scoping and permissions +- 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 migration paths for existing code +- Test that new parameters don't break existing usage ### Phase 5: Documentation and Examples #### 5.1 README Updates - Add conversational usage examples -- Document Rails integration patterns -- Show migration from single-run to conversational usage +- Document message format requirements +- Show different storage strategies (Redis, database, etc.) #### 5.2 Example Applications -- Rails API example with conversation management -- Background job integration for long-running conversations -- Multi-user conversation scenarios +- 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. Enhance `Session` class with persistence methods -2. Add `Agent` conversation management methods -3. Create basic ActiveRecord model +1. Add `Session.from_messages` method +2. Implement `Agent#continue` method +3. Add message export functionality to Session 4. Write comprehensive tests -### Sprint 2: Rails Integration -1. Complete ActiveRecord model with all features -2. Update engines for conversation compatibility -3. Add controller patterns and examples +### 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 optimization +2. Performance testing with large message histories 3. Documentation updates 4. Example applications ## Technical Considerations ### Performance -- JSON serialization for messages/spans should be efficient -- Index conversations by user and recency -- Consider pagination for long conversations -- Lazy loading of spans for large conversation histories +- 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 -- Ensure user can only access their own conversations -- Validate agent class names to prevent code injection -- Sanitize conversation data before persistence +- Validate message format and content +- Ensure no code injection through message content +- Users responsible for securing their own message storage ### Error Handling -- Graceful handling of corrupted session data -- Fallback behavior when session restoration fails -- Clear error messages for invalid conversation states +- Validate message structure on import +- Handle missing or malformed message data gracefully +- Clear error messages for invalid formats ### Scalability -- Design for horizontal scaling (stateless requests) -- Consider separate storage for large conversation histories -- Plan for conversation archival and cleanup +- 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. Gradual migration path with examples and guides -4. No breaking changes to current API - -### Database Migrations -1. Add `regent_conversations` table -2. Optional: Add indexes for performance -3. Provide generator for Rails applications +3. No database requirements or migrations needed +4. Simple upgrade path with examples ## Success Metrics ### Functional Requirements -- [ ] Can start new conversations and persist them -- [ ] Can continue conversations across requests -- [ ] Full message history preserved -- [ ] Execution traces (spans) available for debugging +- [ ] 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 -- [ ] Memory usage doesn't grow with conversation length -- [ ] Rails integration feels natural and idiomatic -- [ ] Clear error messages and debugging capabilities +- [ ] Minimal memory overhead for message handling +- [ ] Clear, simple API that follows Ruby conventions +- [ ] Clear error messages for invalid inputs ## Future Enhancements -### Phase 6+: Advanced Features -- Conversation branching and forking -- Conversation templates and presets -- Real-time conversation streaming -- Conversation analytics and insights -- Multi-agent conversations -- Conversation export/import formats +### 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 -This plan provides a comprehensive roadmap for implementing conversational capabilities while maintaining Regent's elegant simplicity and Ruby idioms. \ No newline at end of file +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 From 331b8f7f8fc720e231107b976ddde5cbb0e78633 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Jun 2025 10:50:19 +1000 Subject: [PATCH 03/10] first pass at code --- README.md | 90 ++++++++++++++ lib/regent/agent.rb | 27 ++++- lib/regent/engine/react.rb | 12 +- lib/regent/session.rb | 92 ++++++++++++++- spec/regent/conversational_spec.rb | 182 +++++++++++++++++++++++++++++ 5 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 spec/regent/conversational_spec.rb diff --git a/README.md b/README.md index dbd5ef9..df140be 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,96 @@ 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.). + +#### Basic Conversation Flow + +```ruby +# Start a new conversation +agent = WeatherAgent.new("You are a helpful weather assistant", model: "gpt-4o") +answer, session = agent.run("What's the weather in London?", return_session: true) +# => "It's currently 15°C and rainy in London" + +# 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 + +# Continue the conversation +agent = WeatherAgent.new("You are a helpful weather assistant", model: "gpt-4o") +answer = agent.continue(stored_messages, "Is that colder than usual?") +# => "Yes, 15°C is about 5 degrees colder than the average for this time of year" +``` + +#### 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") + + if messages.empty? + answer, session = agent.run(params[:message], return_session: true) + else + answer = agent.continue(messages, params[:message]) + session = agent.session + end + + # Save updated messages + 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..5e7c517 100644 --- a/lib/regent/agent.rb +++ b/lib/regent/agent.rb @@ -20,11 +20,34 @@ def initialize(context, model:, tools: [], engine: Regent::Engine::React, **opti attr_reader :context, :sessions, :model, :tools, :inline_tools - def run(task) + def run(task, return_session: false) raise ArgumentError, "Task cannot be empty" if task.to_s.strip.empty? start_session - reason(task) + result = reason(task) + + return_session ? [result, session] : result + ensure + complete_session + end + + # Continues a conversation with existing messages + # @param messages [Array] Array of message hashes from previous conversation + # @param new_task [String] The new user input to continue the conversation + # @return [String] The assistant's response + def continue(messages, new_task) + raise ArgumentError, "Messages cannot be empty" if messages.nil? || messages.empty? + raise ArgumentError, "New task cannot be empty" if new_task.to_s.strip.empty? + + # Create session from messages + @sessions << Session.from_messages(messages) + session.reactivate + + # Add the new user message + session.add_user_message(new_task) + + # Run reasoning to get response + reason(new_task) ensure complete_session end 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..f81c080 100644 --- a/lib/regent/session.rb +++ b/lib/regent/session.rb @@ -18,6 +18,28 @@ 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) + raise ArgumentError, "Message must have :content key" unless message.key?(:content) + raise ArgumentError, "Message role must be :user, :assistant, or :system" unless [:user, :assistant, :system].include?(message[:role]) + raise ArgumentError, "Message content cannot be empty" if message[:content].to_s.strip.empty? + end + attr_reader :id, :spans, :messages, :start_time, :end_time # Starts the session @@ -75,12 +97,76 @@ 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) @messages << message end + + # 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..7b42320 --- /dev/null +++ b/spec/regent/conversational_spec.rb @@ -0,0 +1,182 @@ +# 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 "#continue" 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.continue(messages, "Is that right?") + + 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 "validates inputs" do + expect { + agent.continue([], "Question") + }.to raise_error(ArgumentError, "Messages cannot be empty") + + expect { + agent.continue([{ role: :user, content: "Hi" }], "") + }.to raise_error(ArgumentError, "New task cannot be empty") + 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 - convert back to symbols for internal use + allow(llm).to receive(:invoke).and_return(llm_result2) + answer2 = agent.continue(messages.map { |m| { role: m[:role].to_sym, content: m[:content] } }, "Good! Now what's 3+3?") + + expect(answer2).to include("6") + expect(agent.session.messages.any? { |m| m[:content].include?("2+2") }).to be true + end + end +end \ No newline at end of file From 0b32d02b5105b7aad08985b5402616e575954e92 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Jun 2025 10:58:11 +1000 Subject: [PATCH 04/10] bugs --- README.md | 2 +- lib/regent/agent.rb | 3 --- lib/regent/session.rb | 2 +- spec/regent/conversational_spec.rb | 4 ++-- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index df140be..4c6ba0c 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ class ConversationsController < ApplicationController 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"] } + { role: m["role"], content: m["content"] } } end diff --git a/lib/regent/agent.rb b/lib/regent/agent.rb index 5e7c517..bb27bb0 100644 --- a/lib/regent/agent.rb +++ b/lib/regent/agent.rb @@ -43,9 +43,6 @@ def continue(messages, new_task) @sessions << Session.from_messages(messages) session.reactivate - # Add the new user message - session.add_user_message(new_task) - # Run reasoning to get response reason(new_task) ensure diff --git a/lib/regent/session.rb b/lib/regent/session.rb index f81c080..abfa489 100644 --- a/lib/regent/session.rb +++ b/lib/regent/session.rb @@ -36,7 +36,7 @@ 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) raise ArgumentError, "Message must have :content key" unless message.key?(:content) - raise ArgumentError, "Message role must be :user, :assistant, or :system" unless [:user, :assistant, :system].include?(message[:role]) + raise ArgumentError, "Message role must be :user, :assistant, or :system" unless [:user, :assistant, :system].include?(message[:role].to_sym) raise ArgumentError, "Message content cannot be empty" if message[:content].to_s.strip.empty? end diff --git a/spec/regent/conversational_spec.rb b/spec/regent/conversational_spec.rb index 7b42320..728fced 100644 --- a/spec/regent/conversational_spec.rb +++ b/spec/regent/conversational_spec.rb @@ -173,10 +173,10 @@ # Continue conversation - convert back to symbols for internal use allow(llm).to receive(:invoke).and_return(llm_result2) - answer2 = agent.continue(messages.map { |m| { role: m[:role].to_sym, content: m[:content] } }, "Good! Now what's 3+3?") + answer2 = agent.continue(messages.map { |m| { role: m[:role], content: m[:content] } }, "Good! Now what's 3+3?") expect(answer2).to include("6") expect(agent.session.messages.any? { |m| m[:content].include?("2+2") }).to be true end end -end \ No newline at end of file +end From e3cb464c1c33992fc0a2891049d1c9e6261fb998 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Jun 2025 11:04:38 +1000 Subject: [PATCH 05/10] bugs --- README.md | 6 +++--- lib/regent/session.rb | 16 +++++++++++----- spec/regent/conversational_spec.rb | 4 ++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4c6ba0c..cfeab23 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ messages = session.messages_for_export ```ruby # Later, load messages from your storage and continue stored_messages = load_from_storage() # Your implementation +# The library automatically handles extra fields and format conversion # Continue the conversation agent = WeatherAgent.new("You are a helpful weather assistant", model: "gpt-4o") @@ -271,9 +272,8 @@ class ConversationsController < ApplicationController def load_messages(conversation_id) # Your storage implementation - could be ActiveRecord, Redis, etc. - Conversation.find(conversation_id).messages.map { |m| - { role: m["role"], content: m["content"] } - } + # Can return messages directly - the library handles format conversion + Conversation.find(conversation_id).messages end def save_messages(conversation_id, messages) diff --git a/lib/regent/session.rb b/lib/regent/session.rb index abfa489..9d9a1a4 100644 --- a/lib/regent/session.rb +++ b/lib/regent/session.rb @@ -34,10 +34,14 @@ def self.from_messages(messages) # @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) - raise ArgumentError, "Message must have :content key" unless message.key?(:content) - raise ArgumentError, "Message role must be :user, :assistant, or :system" unless [:user, :assistant, :system].include?(message[:role].to_sym) - raise ArgumentError, "Message content cannot be empty" if message[:content].to_s.strip.empty? + 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 @@ -103,7 +107,9 @@ def add_message(message) raise ArgumentError, "Message cannot be nil" if message.nil? self.class.validate_message_format(message) - @messages << message + role = (message[:role] || message["role"]).to_sym + content = message[:content] || message["content"] + @messages << { role: role, content: content } end # Adds a user message to the conversation diff --git a/spec/regent/conversational_spec.rb b/spec/regent/conversational_spec.rb index 728fced..a0b6fb2 100644 --- a/spec/regent/conversational_spec.rb +++ b/spec/regent/conversational_spec.rb @@ -171,9 +171,9 @@ messages = session1.messages_for_export expect(messages.first[:role]).to be_a(String) - # Continue conversation - convert back to symbols for internal use + # Continue conversation - strip timestamps and convert roles to symbols allow(llm).to receive(:invoke).and_return(llm_result2) - answer2 = agent.continue(messages.map { |m| { role: m[:role], content: m[:content] } }, "Good! Now what's 3+3?") + answer2 = agent.continue(messages.map { |m| { role: m[:role].to_sym, content: m[:content] } }, "Good! Now what's 3+3?") expect(answer2).to include("6") expect(agent.session.messages.any? { |m| m[:content].include?("2+2") }).to be true From 9e133550cad198a06d3704fc6a59663ac60e4df0 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Jun 2025 11:06:15 +1000 Subject: [PATCH 06/10] bugs --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cfeab23..05b9e26 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,13 @@ messages = session.messages_for_export ```ruby # Later, load messages from your storage and continue -stored_messages = load_from_storage() # Your implementation -# The library automatically handles extra fields and format conversion +# Important: Only pass role and content, not timestamp or other fields +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", model: "gpt-4o") @@ -272,8 +277,10 @@ class ConversationsController < ApplicationController def load_messages(conversation_id) # Your storage implementation - could be ActiveRecord, Redis, etc. - # Can return messages directly - the library handles format conversion - Conversation.find(conversation_id).messages + # Important: Only return role and content, not timestamp + Conversation.find(conversation_id).messages.map { |m| + { role: m["role"].to_sym, content: m["content"] } + } end def save_messages(conversation_id, messages) From 3e765d17b3ee39b95688bdc07a295d9774e19822 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Jun 2025 11:15:50 +1000 Subject: [PATCH 07/10] better --- README.md | 46 ++++++++++++++++++++++++++---- lib/regent/agent.rb | 45 +++++++++++++++++++---------- spec/regent/conversational_spec.rb | 27 ++++++++++++++++++ 3 files changed, 97 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 05b9e26..229c4a6 100644 --- a/README.md +++ b/README.md @@ -221,10 +221,33 @@ Regent supports stateless conversation continuation, allowing you to maintain co #### 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", model: "gpt-4o") +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) -# => "It's currently 15°C and rainy in London" +# => "The current weather in London is 72°F and sunny." # Export messages for storage messages = session.messages_for_export @@ -235,7 +258,6 @@ messages = session.messages_for_export ```ruby # Later, load messages from your storage and continue -# Important: Only pass role and content, not timestamp or other fields stored_messages = load_from_storage() # Your implementation should return: # [ # { role: :system, content: "You are a helpful weather assistant" }, @@ -244,9 +266,21 @@ stored_messages = load_from_storage() # Your implementation should return: # ] # Continue the conversation -agent = WeatherAgent.new("You are a helpful weather assistant", model: "gpt-4o") -answer = agent.continue(stored_messages, "Is that colder than usual?") -# => "Yes, 15°C is about 5 degrees colder than the average for this time of year" +agent = WeatherAgent.new("You are a helpful weather assistant. It is #{Date.today}.", model: "gpt-4o") +answer1 = agent.continue(stored_messages, "Is that colder than usual?") +# => "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.continue("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 diff --git a/lib/regent/agent.rb b/lib/regent/agent.rb index bb27bb0..e413027 100644 --- a/lib/regent/agent.rb +++ b/lib/regent/agent.rb @@ -16,6 +16,7 @@ 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 @@ -31,22 +32,35 @@ def run(task, return_session: false) complete_session end - # Continues a conversation with existing messages - # @param messages [Array] Array of message hashes from previous conversation - # @param new_task [String] The new user input to continue the conversation + # Continues a conversation with existing messages or adds to current conversation + # @param messages_or_task [Array, String] Either message history or new task + # @param new_task [String, nil] New task if first param is messages # @return [String] The assistant's response - def continue(messages, new_task) - raise ArgumentError, "Messages cannot be empty" if messages.nil? || messages.empty? - raise ArgumentError, "New task cannot be empty" if new_task.to_s.strip.empty? - - # Create session from messages - @sessions << Session.from_messages(messages) - session.reactivate - - # Run reasoning to get response - reason(new_task) - ensure - complete_session + def continue(messages_or_task, new_task = nil) + # If first argument is a string, we're continuing the current session + if messages_or_task.is_a?(String) + raise ArgumentError, "No active conversation to continue" unless @continuing_session + raise ArgumentError, "Task cannot be empty" if messages_or_task.to_s.strip.empty? + + # Reactivate the session if it was completed + session.reactivate if session.completed? + + # Run reasoning with the new task + reason(messages_or_task) + else + # Otherwise, we're starting a new conversation from messages + messages = messages_or_task + raise ArgumentError, "Messages cannot be empty" if messages.nil? || messages.empty? + raise ArgumentError, "New task cannot be empty" if new_task.to_s.strip.empty? + + # Create session from messages + @sessions << Session.from_messages(messages) + session.reactivate + @continuing_session = true + + # Run reasoning to get response + reason(new_task) + end end def running? @@ -67,6 +81,7 @@ def start_session complete_session @sessions << Session.new session.start + @continuing_session = false # Reset continuation flag end def complete_session diff --git a/spec/regent/conversational_spec.rb b/spec/regent/conversational_spec.rb index a0b6fb2..532c153 100644 --- a/spec/regent/conversational_spec.rb +++ b/spec/regent/conversational_spec.rb @@ -142,6 +142,29 @@ expect(agent.session.messages.last).to eq({ role: :assistant, content: "Answer: Yes, that's correct!" }) end + it "allows continuing the same conversation with just a string" 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 continue with messages + answer1 = agent.continue(messages, "Is that right?") + expect(answer1).to eq("Yes, that's correct!") + + # Second continue with just a string + answer2 = agent.continue("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.continue([], "Question") @@ -150,6 +173,10 @@ expect { agent.continue([{ role: :user, content: "Hi" }], "") }.to raise_error(ArgumentError, "New task cannot be empty") + + expect { + agent.continue("Question without prior context") + }.to raise_error(ArgumentError, "No active conversation to continue") end end end From 19381aadb453dc42b1377a493cc5fe3bfa01f18e Mon Sep 17 00:00:00 2001 From: Alex Ghiculescu Date: Fri, 20 Jun 2025 11:17:57 +1000 Subject: [PATCH 08/10] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 229c4a6..834ff3b 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,6 @@ class ConversationsController < ApplicationController def load_messages(conversation_id) # Your storage implementation - could be ActiveRecord, Redis, etc. - # Important: Only return role and content, not timestamp Conversation.find(conversation_id).messages.map { |m| { role: m["role"].to_sym, content: m["content"] } } From 13f84738d8134727307bbcf0bd2ef22fc12c681c Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Jun 2025 11:36:29 +1000 Subject: [PATCH 09/10] better api --- README.md | 17 +++++---- lib/regent/agent.rb | 57 ++++++++++++++---------------- spec/regent/conversational_spec.rb | 41 +++++++++++++-------- 3 files changed, 62 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 834ff3b..3b481c1 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,11 @@ Outputs: 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 @@ -267,13 +272,13 @@ stored_messages = load_from_storage() # Your implementation should return: # Continue the conversation agent = WeatherAgent.new("You are a helpful weather assistant. It is #{Date.today}.", model: "gpt-4o") -answer1 = agent.continue(stored_messages, "Is that colder than usual?") +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.continue("Thanks. What's the weather in New York? How does it compare to London?") +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) @@ -294,14 +299,8 @@ class ConversationsController < ApplicationController # Create agent and process message agent = ChatAgent.new("You are a helpful assistant", model: "gpt-4o") - if messages.empty? - answer, session = agent.run(params[:message], return_session: true) - else - answer = agent.continue(messages, params[:message]) - session = agent.session - end + answer, session = agent.run(params[:message], messages: messages, return_session: true) - # Save updated messages save_messages(conversation_params[:id], session.messages_for_export) render json: { answer: answer, conversation_id: conversation_params[:id] } diff --git a/lib/regent/agent.rb b/lib/regent/agent.rb index e413027..2593427 100644 --- a/lib/regent/agent.rb +++ b/lib/regent/agent.rb @@ -21,45 +21,42 @@ def initialize(context, model:, tools: [], engine: Regent::Engine::React, **opti attr_reader :context, :sessions, :model, :tools, :inline_tools - def run(task, return_session: false) + # 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 + if messages + # Continue from provided message history + raise ArgumentError, "Messages cannot be empty" if messages.empty? + @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 - # Continues a conversation with existing messages or adds to current conversation - # @param messages_or_task [Array, String] Either message history or new task - # @param new_task [String, nil] New task if first param is messages - # @return [String] The assistant's response + # Legacy method for backward compatibility + # @deprecated Use {#run} with messages parameter instead def continue(messages_or_task, new_task = nil) - # If first argument is a string, we're continuing the current session if messages_or_task.is_a?(String) - raise ArgumentError, "No active conversation to continue" unless @continuing_session - raise ArgumentError, "Task cannot be empty" if messages_or_task.to_s.strip.empty? - - # Reactivate the session if it was completed - session.reactivate if session.completed? - - # Run reasoning with the new task - reason(messages_or_task) + run(messages_or_task) else - # Otherwise, we're starting a new conversation from messages - messages = messages_or_task - raise ArgumentError, "Messages cannot be empty" if messages.nil? || messages.empty? - raise ArgumentError, "New task cannot be empty" if new_task.to_s.strip.empty? - - # Create session from messages - @sessions << Session.from_messages(messages) - session.reactivate - @continuing_session = true - - # Run reasoning to get response - reason(new_task) + run(new_task, messages: messages_or_task) end end @@ -81,7 +78,7 @@ def start_session complete_session @sessions << Session.new session.start - @continuing_session = false # Reset continuation flag + @continuing_session = false end def complete_session diff --git a/spec/regent/conversational_spec.rb b/spec/regent/conversational_spec.rb index 532c153..0f48426 100644 --- a/spec/regent/conversational_spec.rb +++ b/spec/regent/conversational_spec.rb @@ -124,7 +124,7 @@ end end - describe "#continue" do + describe "#run with messages" do it "continues a conversation with existing messages" do messages = [ { role: :system, content: "You are a helpful assistant" }, @@ -135,14 +135,14 @@ 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.continue(messages, "Is that right?") + 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 with just a string" do + 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?" }, @@ -153,12 +153,12 @@ 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 continue with messages - answer1 = agent.continue(messages, "Is that right?") + # First run with messages + answer1 = agent.run("Is that right?", messages: messages) expect(answer1).to eq("Yes, that's correct!") - # Second continue with just a string - answer2 = agent.continue("What's 3+3?") + # 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 @@ -167,16 +167,29 @@ it "validates inputs" do expect { - agent.continue([], "Question") + agent.run("Question", messages: []) }.to raise_error(ArgumentError, "Messages cannot be empty") expect { - agent.continue([{ role: :user, content: "Hi" }], "") - }.to raise_error(ArgumentError, "New task cannot be empty") + 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.continue("Question without prior context") - }.to raise_error(ArgumentError, "No active conversation to continue") + expect(agent).to receive(:run).with("Hello") + agent.continue("Hello") end end end @@ -200,7 +213,7 @@ # Continue conversation - strip timestamps and convert roles to symbols allow(llm).to receive(:invoke).and_return(llm_result2) - answer2 = agent.continue(messages.map { |m| { role: m[:role].to_sym, content: m[:content] } }, "Good! Now what's 3+3?") + 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 From df3f54378ca483be083290fd53c590672daececd Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Jun 2025 12:07:54 +1000 Subject: [PATCH 10/10] more slick --- lib/regent/agent.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/regent/agent.rb b/lib/regent/agent.rb index 2593427..8b9658d 100644 --- a/lib/regent/agent.rb +++ b/lib/regent/agent.rb @@ -29,9 +29,8 @@ def initialize(context, model:, tools: [], engine: Regent::Engine::React, **opti def run(task, messages: nil, return_session: false) raise ArgumentError, "Task cannot be empty" if task.to_s.strip.empty? - if messages + if !messages.nil? && messages.any? # Continue from provided message history - raise ArgumentError, "Messages cannot be empty" if messages.empty? @sessions << Session.from_messages(messages) session.reactivate @continuing_session = true