Feat: Added in memory_service and plan_service features

This commit is contained in:
2025-11-18 17:04:01 -05:00
parent 958c29b18a
commit 4ee19b9977
20 changed files with 2825 additions and 17 deletions
@@ -0,0 +1,268 @@
# ADR-011: LangChain/LangGraph Integration Patterns
## Status
Accepted
## Context
CleverAgents requires sophisticated AI provider integration, workflow orchestration, and agent coordination capabilities. Rather than building custom implementations for each LLM provider and workflow pattern, we need to leverage battle-tested infrastructure that provides:
1. **Unified Provider Interface**: Support for 50+ LLM providers with consistent APIs
2. **Workflow Orchestration**: Stateful, graph-based workflows with checkpointing
3. **Memory Management**: Conversation history, context management, and vector stores
4. **Observability**: Debugging, monitoring, and performance tracking
5. **Resilience**: Built-in retry logic, fallback chains, and error handling
LangChain and LangGraph have emerged as the de facto standards for building LLM applications, providing:
- **LangChain**: Comprehensive toolkit for LLM application development
- **LangGraph**: Stateful workflow orchestration built on LangChain
- **LangSmith**: Optional observability and monitoring platform
## Decision
We will integrate LangChain and LangGraph as the foundation for all AI and workflow capabilities in CleverAgents.
### Architecture Patterns
#### 1. Provider Abstraction
```python
from langchain_core.language_models import BaseLanguageModel
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
class ProviderRegistry:
"""Registry for LangChain-based providers"""
def get_provider(self, name: str) -> BaseLanguageModel:
"""Get provider by name with fallback support"""
providers = {
"openai": ChatOpenAI,
"anthropic": ChatAnthropic,
# ... other providers
}
return providers[name]()
```
#### 2. Graph-Based Workflows
```python
from langgraph.graph import StateGraph, END
from typing import TypedDict
class PlanState(TypedDict):
"""State for plan generation workflow"""
context: list[str]
prompt: str
plan: str
errors: list[str]
def create_plan_graph() -> StateGraph:
"""Create stateful plan generation workflow"""
graph = StateGraph(PlanState)
# Define nodes
graph.add_node("load_context", load_context_node)
graph.add_node("analyze", analyze_requirements_node)
graph.add_node("generate", generate_plan_node)
graph.add_node("validate", validate_plan_node)
# Define edges
graph.set_entry_point("load_context")
graph.add_edge("load_context", "analyze")
graph.add_edge("analyze", "generate")
graph.add_conditional_edges(
"generate",
should_retry,
{
"retry": "analyze",
"continue": "validate"
}
)
graph.add_edge("validate", END)
return graph.compile()
```
#### 3. Memory Patterns
```python
from langchain.memory import ConversationBufferMemory
from langchain_community.chat_message_histories import SQLChatMessageHistory
class ConversationManager:
"""Manage conversation memory with persistence"""
def __init__(self, session_id: str):
self.history = SQLChatMessageHistory(
session_id=session_id,
connection_string="sqlite:///cleveragents.db"
)
self.memory = ConversationBufferMemory(
chat_memory=self.history,
return_messages=True
)
```
#### 4. State Management
```python
from langgraph.checkpoint.sqlite import SqliteSaver
class WorkflowManager:
"""Manage workflow execution with checkpointing"""
def __init__(self):
self.checkpointer = SqliteSaver.from_conn_string(
"cleveragents.db"
)
def execute_workflow(self, graph, input_state, thread_id):
"""Execute workflow with checkpoint support"""
config = {"configurable": {"thread_id": thread_id}}
# Execute with checkpointing
result = graph.invoke(
input_state,
config=config,
checkpointer=self.checkpointer
)
return result
```
### Integration Points
1. **Phase 2 (Runtime)**:
- Use LangGraph for durable execution
- Implement workflow checkpointing
- Stream execution events to CLI
2. **Phase 3 (Persistence)**:
- Extend SQLAlchemy models for LangGraph state
- Use SQLChatMessageHistory for conversations
- Integrate vector stores for semantic search
3. **Phase 4 (Providers)**:
- Replace custom providers with LangChain adapters
- Implement fallback chains
- Add token counting and rate limiting
4. **Phase 5 (Commands)**:
- Human-in-the-loop with graph interrupts
- Interactive execution with breakpoints
- Streaming updates via graph events
5. **Phase 6 (Observability)**:
- LangSmith integration for tracing
- Custom callbacks for metrics
- Cost tracking with token counting
### Testing Strategy
```python
from langchain_core.language_models.fake import FakeListLLM
from langgraph.testing import TestRunner
class TestProviders:
"""Test providers for deterministic testing"""
@staticmethod
def get_fake_llm(responses: list[str]) -> FakeListLLM:
"""Get fake LLM with predefined responses"""
return FakeListLLM(responses=responses)
@staticmethod
def test_graph(graph: StateGraph, test_cases: list):
"""Test graph with deterministic paths"""
runner = TestRunner(graph)
for case in test_cases:
result = runner.run(case.input, case.expected_path)
assert result == case.expected_output
```
### Migration Path
1. **Immediate (Week 9-10)**:
- Convert MockAIProvider to FakeListLLM
- Create base StateGraph classes
- Implement first workflow (PlanGenerationGraph)
2. **Short-term (Week 11-12)**:
- Add memory systems
- Implement streaming
- Create context analysis agents
3. **Medium-term (Phase 4)**:
- Full provider migration
- Complex workflow patterns
- Advanced agent collaboration
## Consequences
### Positive
- **Reduced Complexity**: Leverage battle-tested infrastructure instead of custom implementations
- **Provider Flexibility**: Support for 50+ providers out of the box
- **Advanced Features**: Checkpointing, streaming, memory, vector stores included
- **Community Support**: Active ecosystem with regular updates
- **Observability**: Built-in debugging and monitoring capabilities
- **Standardization**: Industry-standard patterns and practices
### Negative
- **Additional Dependencies**: Increases dependency footprint
- **Learning Curve**: Team needs to learn LangChain/LangGraph patterns
- **Version Management**: Need to track LangChain ecosystem updates
- **Potential Breaking Changes**: Major versions may require migration
### Neutral
- **Documentation**: Extensive but sometimes overwhelming documentation
- **Flexibility**: Can override defaults but requires understanding internals
- **Performance**: Generally good but depends on provider and configuration
## Implementation Guidelines
### DO:
- Use LangChain's built-in providers over custom implementations
- Leverage LangGraph for all stateful workflows
- Implement proper error handling with fallback chains
- Use deterministic testing with FakeListLLM
- Follow LangChain's prompt engineering best practices
- Document graph flows with Mermaid diagrams
### DON'T:
- Don't bypass LangChain abstractions unless absolutely necessary
- Don't store sensitive data in LangSmith (if using)
- Don't use deprecated LangChain patterns
- Don't implement custom retry logic (use built-in)
- Don't ignore token limits and costs
### Code Organization:
```
src/cleveragents/
├── agents/
│ ├── __init__.py
│ ├── base.py # Base graph classes
│ ├── graphs/
│ │ ├── __init__.py
│ │ ├── plan_generation.py
│ │ ├── context_analysis.py
│ │ └── auto_debug.py
│ ├── nodes/ # Reusable graph nodes
│ │ ├── __init__.py
│ │ ├── llm.py
│ │ ├── tools.py
│ │ └── validators.py
│ └── memory/ # Memory implementations
│ ├── __init__.py
│ ├── conversation.py
│ └── context.py
├── providers/
│ ├── __init__.py
│ ├── registry.py # Provider registry
│ ├── config.py # Provider configuration
│ └── fallback.py # Fallback chains
```
## References
- [LangChain Documentation](https://python.langchain.com/)
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
- [LangSmith Documentation](https://docs.smith.langchain.com/)
- ADR-002: Asyncio Concurrency Model
- ADR-003: Dependency Injection Framework
- ADR-004: Pydantic for Data Validation
- ADR-007: Repository Pattern for Persistence
+32
View File
@@ -316,3 +316,35 @@ Feature: Base Agent Coverage
When I create a concrete agent with model "custom-model"
Then the agent model should be "custom-model" in base agent
And the llm should be created with model "custom-model"
Scenario: Real BaseAgent subclass exercises base workflow methods
Given I have a real BaseAgent subclass instance in base agent
When I invoke the agent without providing config in base agent
Then the result should be returned in base agent
And the default config should be used in base agent
And the real base agent should record invoke thread "default"
When I ainvoke the agent without providing config in base agent
Then the async result should be returned in base agent
And the default config should be used for async in base agent
And the real base agent should record async thread "default"
When I stream the agent without providing config in base agent
Then the default config should be used for streaming in base agent
And each event should be yielded in base agent
And the real base agent should record stream thread "default"
Scenario: Real BaseAgent subclass surfaces workflow errors from base methods
Given I have a real BaseAgent subclass instance in base agent
When the workflow execution raises an exception in base agent with message "real invoke boom"
And I invoke the agent with input data in base agent
Then the error should be caught in base agent
And the result should contain an error field in base agent
And the result should preserve input messages in base agent
When the async workflow execution raises an exception in base agent with message "real async boom"
And I ainvoke the agent with input data in base agent
Then the async error should be caught in base agent
And the async result should contain an error field in base agent
And the async result should preserve input messages in base agent
When the stream execution raises an exception in base agent with message "real stream boom"
And I stream the agent with input data in base agent
Then the stream error should be caught in base agent
And an error event should be yielded in base agent
+49
View File
@@ -0,0 +1,49 @@
Feature: CleverAgents base module coverage
To increase coverage on cleveragents.agents.base
We exercise the BaseAgent defaults and workflow helpers
Background:
Given the cleveragents base agent module is loaded with stub dependencies
Scenario: Instantiating a base agent test double builds default components
Given I instantiate a base agent test double with defaults
Then the fake list LLM should be constructed once
And the memory saver should be constructed once
And the compiled graph should receive the created memory saver
And the agent should expose a compiled application
Scenario: Base implementation of _build_graph raises a descriptive error
When I call the base agent _build_graph implementation
Then a NotImplementedError should mention subclasses must implement the graph
Scenario: Invoke applies default config when no config is provided
Given I instantiate a base agent test double with defaults
When I invoke the test agent with message "hello" and no config
Then the underlying invoke config should use thread_id "default"
And the invoke result should include message "hello"
Scenario: Invoke preserves an explicit config dictionary
Given I instantiate a base agent test double with defaults
When I invoke the test agent with message "custom" and config thread_id "thread-42"
Then the underlying invoke config should use thread_id "thread-42"
Scenario: Async invoke applies default config by default
Given I instantiate a base agent test double with defaults
When I asynchronously invoke the test agent with message "async-one" and no config
Then the async invocation config should use thread_id "default"
And the async result should include message "async-one"
Scenario: Async invoke honors an explicit config
Given I instantiate a base agent test double with defaults
When I asynchronously invoke the test agent with message "async-two" and config thread_id "async-99"
Then the async invocation config should use thread_id "async-99"
Scenario: Stream uses default config when omitted
Given I instantiate a base agent test double with defaults
When I stream the test agent with message "stream-one" and no config
Then the stream events should include thread_id "default"
Scenario: Stream honors a provided config
Given I instantiate a base agent test double with defaults
When I stream the test agent with message "stream-two" and config thread_id "stream-7"
Then the stream events should include thread_id "stream-7"
+42
View File
@@ -0,0 +1,42 @@
Feature: Memory Service Coverage
As a developer
I want to exercise memory service behavior
So that uncovered logic gains test coverage
Scenario: Conversation adapter returns buffer text when configured for summaries
Given a conversation adapter configured to return text values
When I load memory variables from the adapter
Then the adapter provides a string buffer representation
Scenario: Conversation adapter handles nested context and async operations
Given a conversation adapter with prune tracking
When I save nested user and ai context through the adapter
And I invoke the adapter async operations
Then the adapter normalizes all values and prunes history three times
Scenario: Memory service prunes history to the configured window
Given a memory service limited to 3 messages
When I add five interactions via save context
And I ask for the two most recent messages
And I ask for more messages than available
Then the service keeps only the three latest messages
And the recent message requests respect their sizes
And the service summary honors a 20 character limit
And memory variables expose history and counts
Scenario: Memory service manages direct messages and adapters
Given a memory service with default settings
When I add direct message objects to the service
And I inspect the default conversation adapter
And I reset the max message limit to 1
And I save a new interaction to enforce pruning
And I clear the service memory
Then the service computed token counts and summaries before clearing
And the service history is empty after clearing
And a custom conversation adapter returns text values
Scenario: Memory service uses SQL chat history when available
Given a patched SQL chat history for memory service
When I create a memory service with a connection string
Then the SQL history constructor receives the session and connection
And stored messages flow through the patched SQL history
+213
View File
@@ -0,0 +1,213 @@
"""LangChain-based mock AI provider for testing purposes only.
This mock implementation uses LangChain's FakeListLLM for deterministic testing,
following the mock placement rule that all mocks must exist in the features/
directory, never in production code (ADR-022).
"""
from collections.abc import Callable
from typing import Any
from langchain_community.llms import FakeListLLM
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from cleveragents.domain.models.core import (
Change,
Context,
OperationType,
Plan,
Project,
)
from cleveragents.domain.providers.ai_provider import (
ProviderResponse,
)
class LangChainMockProvider:
"""LangChain-based mock AI provider that generates deterministic test responses.
This provider uses LangChain's FakeListLLM for deterministic testing,
avoiding calls to real AI APIs during tests.
"""
def __init__(self, model_id: str = "langchain-mock-gpt-4"):
"""Initialize the LangChain mock provider.
Args:
model_id: Mock model identifier
"""
self._model_id = model_id
self._name = "LangChainMockProvider"
# Define deterministic responses for different prompt patterns
self._responses = [
"""# Error handling code
def handle_error(error):
\"\"\"Handle errors gracefully.\"\"\"
import logging
logging.error(f"Error occurred: {error}")
return None
""",
"""# Test file
import pytest
def test_example():
\"\"\"Test example functionality.\"\"\"
assert True
def test_error_handling():
\"\"\"Test error handling.\"\"\"
with pytest.raises(ValueError):
raise ValueError("Test error")
""",
"""# Refactored code
class RefactoredClass:
\"\"\"Improved implementation.\"\"\"
def __init__(self):
self.state = "initialized"
def process(self, data):
\"\"\"Process data with better error handling.\"\"\"
if not data:
return None
return f"Processed: {data}"
""",
"""# Generated code
def main():
\"\"\"Main entry point.\"\"\"
print("Hello from CleverAgents!")
return 0
if __name__ == "__main__":
import sys
sys.exit(main())
""",
]
# Initialize FakeListLLM with responses
self._llm = FakeListLLM(responses=self._responses)
# Create a simple prompt template
self._prompt_template = PromptTemplate(
input_variables=["instruction", "context"],
template="""
You are a code generation assistant. Generate code based on the following:
Instruction: {instruction}
Context Files:
{context}
Generated Code:
""",
)
# Create chain
self._chain = self._prompt_template | self._llm | StrOutputParser()
def generate_changes(
self,
project: Project,
plan: Plan,
contexts: list[Context],
progress_callback: Callable[[int], None] | None = None,
) -> ProviderResponse:
"""Generate mock code changes based on the plan using LangChain.
Args:
project: The project being modified
plan: The plan containing instructions
contexts: List of context files to consider
progress_callback: Optional callback for progress updates (0-100)
Returns:
ProviderResponse containing mock changes
"""
# Simulate progress
if progress_callback:
for i in range(0, 101, 20):
progress_callback(i)
# Prepare context for the prompt
context_str = ""
if contexts:
context_str = "\n".join(
f"File: {ctx.path}\nContent: {ctx.content[:200]}..."
for ctx in contexts[:3] # Limit to first 3 for brevity
)
# Use LangChain to generate response
prompt_lower = plan.prompt.lower() if plan.prompt else ""
try:
# Invoke the chain with the prompt
generated_code = self._chain.invoke(
{
"instruction": plan.prompt or "Generate example code",
"context": context_str or "No context files provided",
}
)
except Exception:
# Fallback to default if chain fails
generated_code = (
"# Default generated code\nprint('Hello from CleverAgents!')\n"
)
# Determine file path and operation based on prompt
if "error handling" in prompt_lower or "exception" in prompt_lower:
file_path = "error_handler.py"
operation = OperationType.CREATE
elif "test" in prompt_lower:
file_path = "test_example.py"
operation = OperationType.CREATE
elif "refactor" in prompt_lower or "improve" in prompt_lower:
if contexts and len(contexts) > 0:
file_path = contexts[0].path
operation = OperationType.MODIFY
else:
file_path = "refactored.py"
operation = OperationType.CREATE
else:
file_path = "generated.py"
operation = OperationType.CREATE
# Create change object
plan_id = plan.id if plan.id else 0 # Use 0 as placeholder for tests
changes = [
Change(
id=None,
plan_id=plan_id,
file_path=file_path,
operation=operation,
original_content=contexts[0].content
if contexts and operation == OperationType.MODIFY
else None,
new_content=generated_code,
applied=False,
applied_at=None,
new_path=None,
)
]
# Calculate approximate token count (rough estimate)
token_count = len(generated_code.split()) * 1.3 # Rough approximation
return ProviderResponse(
changes=changes,
model_used=self._model_id,
token_count=int(token_count),
error_message=None,
)
@property
def name(self) -> str:
"""Get the provider name."""
return self._name
@property
def model_id(self) -> str:
"""Get the model identifier."""
return self._model_id
+21 -1
View File
@@ -119,4 +119,24 @@ Feature: Plan Service Coverage
And the plan has 3 applied and 2 unapplied changes
When I get pending changes for the project
Then I should get exactly 2 pending changes
And the pending changes should not include applied ones
And the pending changes should not include applied ones
Scenario: Toggle plan memory persistence and limits
When I request an in-memory memory service for session "memory-session" with max messages 2
Then the session "memory-session" memory should be in-memory with max messages 2
When I request a persistent memory service for session "memory-session" with max messages 3
Then the session "memory-session" memory should be persistent with max messages 3
When I request a persistent memory service for session "memory-session" with max messages 5
Then the session "memory-session" memory should be persistent with max messages 5
When I request an in-memory memory service for session "memory-session" without changing message limit
Then the session "memory-session" memory should be in-memory without a connection string
Scenario: Provide conversation memory adapter for session context
When I request conversation memory for session "memory-session" with custom keys
Then the conversation adapter should expose the custom keys
And the conversation adapter should share the existing memory service
Scenario: Clear session memory and forget history
Given I stored a chat message in session "memory-session"
When I clear the session "memory-session" memory with forget history
Then the session "memory-session" memory should be removed and emptied
+85 -1
View File
@@ -30,6 +30,12 @@ Feature: Retry Patterns Implementation
Then the function should retry with exponential jitter
And the function should respect rate limit backoff
Scenario: File operation retry recovers from transient OSError
Given I have a file operation that fails with OSError once
When I apply file retry pattern
Then the file operation should eventually succeed
And the file operation should be invoked 2 times
Scenario: Circuit breaker opens after threshold
Given I have a circuit breaker with threshold 3
When a function fails 3 times
@@ -48,6 +54,16 @@ Feature: Retry Patterns Implementation
Then the retry context should track 3 attempts
And the errors should be recorded
Scenario: Retry context manager records exceptions raised in block
Given I have a retry context for "context block"
When I execute a failing block under the retry context manager
Then the retry context manager should capture the exception
Scenario: Async retry context manager records exceptions raised in block
Given I have a retry context for "async context block"
When I execute a failing block under the async retry context manager
Then the async retry context manager should capture the exception
Scenario: Retry with jitter prevents thundering herd
Given I have multiple concurrent operations
When I apply retry with jitter
@@ -66,4 +82,72 @@ Feature: Retry Patterns Implementation
Then network operations should retry 5 times
And provider operations should retry 3 times
And database operations should retry 3 times
And file operations should retry 3 times
And file operations should retry 3 times
Scenario: Retry logging handles loggers without keyword support
Given the retry logger rejects keyword arguments
When I invoke logging callbacks for failed and successful attempts
Then the fallback logging messages should be recorded
Scenario: Circuit breaker half-open transitions recover and reopen
Given I have a half-open circuit breaker with threshold 2
When I record a successful half-open call
And I record another successful half-open call
Then the circuit breaker should close after consecutive successes
When I simulate a half-open failure
Then the circuit breaker should reopen after half-open failure
Scenario: Async circuit breaker enforces open and recovery transitions
Given I have an async circuit breaker with threshold 1
When I trigger an async failure on the circuit breaker
Then the async circuit breaker should raise immediately while open
When the recovery timeout expires
And I perform two async successful calls after recovery
Then the async circuit breaker should close after async successes
Scenario: should_retry_result handles error payloads
Given I have a response payload with error "timeout detected"
When I evaluate the retry decision
Then the retry decision should be True
Scenario: should_retry_result handles server error status codes
Given I have a response payload with status code 503
When I evaluate the retry decision
Then the retry decision should be True
Scenario: should_retry_result returns False for None payloads
Given I have a result payload set to None
When I evaluate the retry decision
Then the retry decision should be False
Scenario: should_retry_result returns False for non-error dictionaries
Given I have a response payload with status code 200
When I evaluate the retry decision
Then the retry decision should be False
Scenario: should_retry_result returns False for non-dictionary results
Given I have a result payload set to the string "ok"
When I evaluate the retry decision
Then the retry decision should be False
Scenario: Unknown retry category falls back to default decorator
When I request a retry decorator for "unknown-category"
And I decorate a function returning "decorated result"
Then the decorated function should execute successfully
Scenario: configure_retry_logging sets tenacity logger levels
When I configure retry logging to warning level
Then the tenacity loggers should be set to warning
Scenario: Auto-debug retry raises the last error message when unresolved
Given I have a function that returns persistent error payloads
And I have a debug callback that never fixes issues
When I apply auto-debug retry expecting failure
Then the auto-debug retry should raise the last error message
Scenario: Auto-debug retry logs debug callback failures
Given I have a function that returns persistent error payloads
And I have a debug callback that raises errors
When I apply auto-debug retry expecting failure
Then the auto-debug retry should raise the debug callback failure
And the debug callback failures should be counted
@@ -141,6 +141,62 @@ def step_have_mock_llm_provider_base_agent(context):
context.mock_llm.invoke.return_value = mock_response
@given("I have a real BaseAgent subclass instance in base agent")
def step_have_real_base_agent(context):
"""Instantiate a real BaseAgent subclass to exercise BaseAgent methods."""
from cleveragents.application.agents.base_agent import BaseAgent
class FakeApp:
"""Simple compiled app capturing configs for coverage checks."""
def __init__(self):
self.last_invoke_config = None
self.last_ainvoke_config = None
self.last_stream_config = None
def invoke(self, input_data, config):
self.last_invoke_config = config
return {
"status": "invoke-success",
"messages": input_data.get("messages", []),
}
async def ainvoke(self, input_data, config):
self.last_ainvoke_config = config
return {
"status": "ainvoke-success",
"messages": input_data.get("messages", []),
}
def stream(self, input_data, config):
self.last_stream_config = config
yield {
"status": "stream-success",
"messages": input_data.get("messages", []),
}
class FakeGraph:
"""Minimal graph stub returning the fake compiled app."""
def __init__(self):
self.app = FakeApp()
def compile(self, checkpointer):
return self.app
class RealBaseAgent(BaseAgent):
"""Concrete subclass delegating workflow methods to BaseAgent."""
def _create_llm(self):
return MagicMock()
def _build_graph(self):
return FakeGraph()
context.agent = RealBaseAgent()
context.fake_app = context.agent.app
@when("I try to instantiate BaseAgent directly")
def step_try_instantiate_base_agent_directly(context):
"""Try to instantiate BaseAgent directly."""
@@ -602,6 +658,14 @@ def step_config_thread_id_is(context, thread_id):
assert context.invoke_config["configurable"]["thread_id"] == thread_id
@then('the real base agent should record invoke thread "{thread_id}"')
def step_real_agent_invoke_thread(context, thread_id):
"""Verify invoke path stored the thread identifier."""
recorded_config = getattr(context.fake_app, "last_invoke_config", None)
assert recorded_config is not None
assert recorded_config["configurable"]["thread_id"] == thread_id
@when("the workflow execution raises an exception in base agent")
def step_workflow_raises_exception(context):
"""Set up workflow to raise exception."""
@@ -707,6 +771,14 @@ def step_custom_config_used_async(context):
assert context.ainvoke_config is not None
@then('the real base agent should record async thread "{thread_id}"')
def step_real_agent_async_thread(context, thread_id):
"""Verify async path stored the thread identifier."""
recorded_config = getattr(context.fake_app, "last_ainvoke_config", None)
assert recorded_config is not None
assert recorded_config["configurable"]["thread_id"] == thread_id
@when("the async workflow execution raises an exception in base agent")
def step_async_workflow_raises_exception(context):
"""Set up async workflow to raise exception."""
@@ -807,6 +879,14 @@ def step_custom_config_used_streaming(context):
assert context.stream_config is not None
@then('the real base agent should record stream thread "{thread_id}"')
def step_real_agent_stream_thread(context, thread_id):
"""Verify stream path stored the thread identifier."""
recorded_config = getattr(context.fake_app, "last_stream_config", None)
assert recorded_config is not None
assert recorded_config["configurable"]["thread_id"] == thread_id
@when("the stream execution raises an exception in base agent")
def step_stream_raises_exception(context):
"""Set up stream to raise exception."""
+363
View File
@@ -0,0 +1,363 @@
"""Behave steps targeting cleveragents.agents.base coverage."""
from __future__ import annotations
import asyncio
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
from typing import Any, Dict, List
from behave import given, then, when
BASE_MODULE_PATH = (
Path(__file__).resolve().parents[2] / "src" / "cleveragents" / "agents" / "base.py"
)
class RecordingApp:
"""Minimal callable graph application that records interactions."""
def __init__(self) -> None:
self.invoke_calls: List[Dict[str, Any]] = []
self.ainvoke_calls: List[Dict[str, Any]] = []
self.stream_calls: List[Dict[str, Any]] = []
def invoke(
self, input_data: Dict[str, Any], config: Dict[str, Any]
) -> Dict[str, Any]:
call = {"input": input_data, "config": config}
self.invoke_calls.append(call)
return {"input": input_data, "config": config}
async def ainvoke(
self, input_data: Dict[str, Any], config: Dict[str, Any]
) -> Dict[str, Any]:
call = {"input": input_data, "config": config}
self.ainvoke_calls.append(call)
return {"input": input_data, "config": config, "async": True}
def stream(self, input_data: Dict[str, Any], config: Dict[str, Any]):
call = {"input": input_data, "config": config}
self.stream_calls.append(call)
yield {"event": "start", "config": config}
yield {"event": "payload", "input": input_data, "config": config}
yield {"event": "done", "config": config}
class TestGraph:
"""Graph stub that remembers the checkpointer used during compilation."""
def __init__(self, context: Any) -> None:
self.context = context
self.compile_checkpointers: List[Any] = []
def compile(self, checkpointer: Any = None) -> RecordingApp:
self.compile_checkpointers.append(checkpointer)
compiled_app = RecordingApp()
self.context.recording_app = compiled_app
return compiled_app
def _register_cleanup(context: Any, callback) -> None:
if hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers.append(callback)
else: # pragma: no cover - fallback safety
context._cleanup_handlers = [callback]
def _prepare_base_module(context: Any) -> None:
"""Stub external dependencies and load the base agent module."""
recorded_modules: Dict[str, ModuleType | None] = {}
def set_module(name: str, module: ModuleType) -> ModuleType:
if name not in recorded_modules:
recorded_modules[name] = sys.modules.get(name)
sys.modules[name] = module
return module
def ensure_package(name: str) -> ModuleType:
if name in sys.modules:
if name not in recorded_modules:
recorded_modules[name] = sys.modules[name]
module = sys.modules[name]
else:
module = ModuleType(name)
module.__path__ = [] # type: ignore[attr-defined]
recorded_modules[name] = None
sys.modules[name] = module
return module
# Stub langchain_core.language_models
langchain_core = ensure_package("langchain_core")
language_models = ModuleType("langchain_core.language_models")
class StubBaseLanguageModel: # noqa: D401 - simple stub
"""Placeholder for BaseLanguageModel."""
language_models.BaseLanguageModel = StubBaseLanguageModel # type: ignore[attr-defined]
set_module("langchain_core.language_models", language_models)
langchain_core.language_models = language_models # type: ignore[attr-defined]
# Stub langchain_community.llms
langchain_community = ensure_package("langchain_community")
llms_module = ModuleType("langchain_community.llms")
class StubFakeListLLM: # noqa: D401 - simple stub
"""Placeholder for FakeListLLM that tracks instantiations."""
instances: List["StubFakeListLLM"] = []
def __init__(self, responses: List[str], sleep: float) -> None:
self.responses = responses
self.sleep = sleep
StubFakeListLLM.instances.append(self)
llms_module.FakeListLLM = StubFakeListLLM # type: ignore[attr-defined]
set_module("langchain_community.llms", llms_module)
langchain_community.llms = llms_module # type: ignore[attr-defined]
# Stub langgraph modules
langgraph = ensure_package("langgraph")
graph_module = ModuleType("langgraph.graph")
class StubStateGraph: # noqa: D401 - simple stub
"""Placeholder for StateGraph."""
def __init__(self, state_schema: Any) -> None:
self.state_schema = state_schema
graph_module.StateGraph = StubStateGraph # type: ignore[attr-defined]
set_module("langgraph.graph", graph_module)
langgraph.graph = graph_module # type: ignore[attr-defined]
checkpoint_pkg = ensure_package("langgraph.checkpoint")
memory_module = ModuleType("langgraph.checkpoint.memory")
class StubMemorySaver: # noqa: D401 - simple stub
"""Placeholder for MemorySaver that tracks instantiations."""
instances: List["StubMemorySaver"] = []
def __init__(self) -> None:
StubMemorySaver.instances.append(self)
memory_module.MemorySaver = StubMemorySaver # type: ignore[attr-defined]
set_module("langgraph.checkpoint.memory", memory_module)
checkpoint_pkg.memory = memory_module # type: ignore[attr-defined]
context.stub_classes = {
"FakeListLLM": StubFakeListLLM,
"MemorySaver": StubMemorySaver,
}
# Create placeholder package hierarchy for module loading
package_names = [
"behave_support",
"behave_support.cleveragents",
"behave_support.cleveragents.agents",
]
for package_name in package_names:
if package_name in sys.modules:
if package_name not in recorded_modules:
recorded_modules[package_name] = sys.modules[package_name]
else:
pkg = ModuleType(package_name)
pkg.__path__ = [] # type: ignore[attr-defined]
set_module(package_name, pkg)
module_name = "behave_support.cleveragents.agents.base"
recorded_modules[module_name] = sys.modules.get(module_name)
spec = importlib.util.spec_from_file_location(module_name, BASE_MODULE_PATH)
if spec is None or spec.loader is None: # pragma: no cover - defensive
raise RuntimeError("Unable to load cleveragents.agents.base module")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
sys.modules["behave_support.cleveragents.agents"].base = module # type: ignore[attr-defined]
context.base_module = module
context.BaseAgent = module.BaseAgent
context.BaseStateGraph = module.BaseStateGraph
def cleanup() -> None:
StubFakeListLLM.instances.clear()
StubMemorySaver.instances.clear()
for name, original in recorded_modules.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
_register_cleanup(context, cleanup)
def _build_input(message: str) -> Dict[str, Any]:
return {
"messages": [{"role": "user", "content": message}],
"context": {},
}
@given("the cleveragents base agent module is loaded with stub dependencies")
def step_load_base_module(context: Any) -> None:
_prepare_base_module(context)
@given("I instantiate a base agent test double with defaults")
def step_instantiate_test_agent(context: Any) -> None:
FakeListLLM = context.stub_classes["FakeListLLM"]
MemorySaver = context.stub_classes["MemorySaver"]
FakeListLLM.instances.clear()
MemorySaver.instances.clear()
context.latest_graph = None
context.recording_app = None
BaseAgent = context.BaseAgent
class TestAgent(BaseAgent):
def _build_graph(self_inner) -> TestGraph: # type: ignore[override]
graph = TestGraph(context)
context.latest_graph = graph
return graph
context.agent = TestAgent()
context.compiled_app = context.recording_app
context.invocation_result = None
context.async_result = None
context.stream_output = None
@then("the fake list LLM should be constructed once")
def step_verify_fake_llm(context: Any) -> None:
FakeListLLM = context.stub_classes["FakeListLLM"]
assert len(FakeListLLM.instances) == 1
fake_instance = FakeListLLM.instances[0]
assert fake_instance.responses == ["Mock response"]
assert fake_instance.sleep == 0.1
@then("the memory saver should be constructed once")
def step_verify_memory_saver(context: Any) -> None:
MemorySaver = context.stub_classes["MemorySaver"]
assert len(MemorySaver.instances) == 1
@then("the compiled graph should receive the created memory saver")
def step_verify_compile_checkpointer(context: Any) -> None:
MemorySaver = context.stub_classes["MemorySaver"]
assert context.latest_graph is not None
assert context.latest_graph.compile_checkpointers
assert context.latest_graph.compile_checkpointers[0] is MemorySaver.instances[0]
@then("the agent should expose a compiled application")
def step_verify_compiled_app(context: Any) -> None:
assert context.agent.app is context.compiled_app
assert isinstance(context.compiled_app, RecordingApp)
@when("I call the base agent _build_graph implementation")
def step_call_base_build_graph(context: Any) -> None:
base_class = context.BaseAgent
bare_instance = object.__new__(base_class)
try:
context.build_graph_error = None
base_class._build_graph(bare_instance)
except NotImplementedError as exc: # pragma: no cover - expected path
context.build_graph_error = exc
@then("a NotImplementedError should mention subclasses must implement the graph")
def step_verify_not_implemented(context: Any) -> None:
assert context.build_graph_error is not None
assert "must implement" in str(context.build_graph_error)
@when('I invoke the test agent with message "{message}" and no config')
def step_invoke_without_config(context: Any, message: str) -> None:
input_data = _build_input(message)
context.invocation_result = context.agent.invoke(input_data)
@when(
'I invoke the test agent with message "{message}" and config thread_id "{thread_id}"'
)
def step_invoke_with_config(context: Any, message: str, thread_id: str) -> None:
input_data = _build_input(message)
config = {"configurable": {"thread_id": thread_id}}
context.invocation_result = context.agent.invoke(input_data, config)
@then('the underlying invoke config should use thread_id "{thread_id}"')
def step_verify_invoke_config(context: Any, thread_id: str) -> None:
assert context.recording_app.invoke_calls, "No invocation recorded"
config = context.recording_app.invoke_calls[-1]["config"]
assert config["configurable"]["thread_id"] == thread_id
@then('the invoke result should include message "{message}"')
def step_verify_invoke_result_message(context: Any, message: str) -> None:
assert context.invocation_result is not None
messages = context.invocation_result["input"]["messages"]
assert messages[0]["content"] == message
@when('I asynchronously invoke the test agent with message "{message}" and no config')
def step_async_invoke_default(context: Any, message: str) -> None:
input_data = _build_input(message)
context.async_result = asyncio.run(context.agent.ainvoke(input_data))
@when(
'I asynchronously invoke the test agent with message "{message}" '
'and config thread_id "{thread_id}"'
)
def step_async_invoke_custom(context: Any, message: str, thread_id: str) -> None:
input_data = _build_input(message)
config = {"configurable": {"thread_id": thread_id}}
context.async_result = asyncio.run(context.agent.ainvoke(input_data, config))
@then('the async invocation config should use thread_id "{thread_id}"')
def step_verify_async_config(context: Any, thread_id: str) -> None:
assert context.recording_app.ainvoke_calls, "No async invocation recorded"
config = context.recording_app.ainvoke_calls[-1]["config"]
assert config["configurable"]["thread_id"] == thread_id
@then('the async result should include message "{message}"')
def step_verify_async_result(context: Any, message: str) -> None:
assert context.async_result is not None
messages = context.async_result["input"]["messages"]
assert messages[0]["content"] == message
@when('I stream the test agent with message "{message}" and no config')
def step_stream_default(context: Any, message: str) -> None:
input_data = _build_input(message)
context.stream_output = list(context.agent.stream(input_data))
@when(
'I stream the test agent with message "{message}" and config thread_id "{thread_id}"'
)
def step_stream_custom(context: Any, message: str, thread_id: str) -> None:
input_data = _build_input(message)
config = {"configurable": {"thread_id": thread_id}}
context.stream_output = list(context.agent.stream(input_data, config))
@then('the stream events should include thread_id "{thread_id}"')
def step_verify_stream_events(context: Any, thread_id: str) -> None:
assert context.recording_app.stream_calls, "No stream invocation recorded"
config = context.recording_app.stream_calls[-1]["config"]
assert config["configurable"]["thread_id"] == thread_id
assert context.stream_output is not None
assert any(
event.get("config", {}).get("configurable", {}).get("thread_id") == thread_id
for event in context.stream_output
)
@@ -0,0 +1,357 @@
"""Behave steps covering memory_service module."""
from __future__ import annotations
import asyncio
import shutil
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import patch
from behave import given, then, when
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from cleveragents.application.services.memory_service import (
ConversationBufferMemoryAdapter,
MemoryService,
)
@given("a conversation adapter configured to return text values")
def step_adapter_returning_text(context: Any) -> None:
"""Create an adapter that returns buffer strings."""
history = InMemoryChatMessageHistory()
history.add_user_message("Hello from user")
history.add_ai_message("Greetings from AI")
context.adapter = ConversationBufferMemoryAdapter(
history,
return_messages=False,
)
context.adapter_keys = context.adapter.memory_variables
@when("I load memory variables from the adapter")
def step_load_memory_variables(context: Any) -> None:
"""Load memory payload from the adapter."""
context.loaded_memory = context.adapter.load_memory_variables()
@then("the adapter provides a string buffer representation")
def step_validate_string_payload(context: Any) -> None:
"""Ensure adapter returns buffer string when configured."""
assert context.adapter.memory_key in context.loaded_memory
payload = context.loaded_memory[context.adapter.memory_key]
assert isinstance(payload, str)
assert payload
assert "Hello from user" in payload or "Greetings from AI" in payload
assert context.adapter_keys == [context.adapter.memory_key]
@given("a conversation adapter with prune tracking")
def step_adapter_with_prune(context: Any) -> None:
"""Create adapter that records prune callback invocations."""
history = InMemoryChatMessageHistory()
context.prune_calls = 0
def record_prune() -> None:
context.prune_calls += 1
context.adapter = ConversationBufferMemoryAdapter(
history, prune_callback=record_prune
)
context.adapter_keys = context.adapter.memory_variables
@when("I save nested user and ai context through the adapter")
def step_save_nested_context(context: Any) -> None:
"""Store nested values to exercise adapter recursion."""
inputs = {
"input": [
HumanMessage(content="first human"),
"plain user",
["nested user", HumanMessage(content="second human")],
None,
]
}
outputs = {
"output": [
AIMessage(content="first ai"),
"plain ai",
[AIMessage(content="second ai"), "another ai"],
None,
]
}
context.adapter.save_context(inputs, outputs)
context.saved_snapshot = [
msg.content for msg in context.adapter._message_history.messages
]
@when("I invoke the adapter async operations")
def step_async_adapter_ops(context: Any) -> None:
"""Exercise async adapter APIs."""
async def run_ops() -> None:
context.async_loaded = await context.adapter.aload_memory_variables({})
await context.adapter.asave_context(
{"input": [HumanMessage(content="async human"), ["async text"]]},
{"output": [AIMessage(content="async ai"), ["async reply"]]},
)
context.post_async_snapshot = [
msg.content for msg in context.adapter._message_history.messages
]
await context.adapter.aclear()
asyncio.run(run_ops())
@then("the adapter normalizes all values and prunes history three times")
def step_verify_prune_and_storage(context: Any) -> None:
"""Validate adapter processed values and triggered pruning."""
assert context.prune_calls == 3
assert len(context.saved_snapshot) >= 4
assert any("plain user" in content for content in context.saved_snapshot)
async_payload = context.async_loaded[context.adapter.memory_key]
assert isinstance(async_payload, list)
assert any(isinstance(msg, BaseMessage) for msg in async_payload)
assert len(context.post_async_snapshot) >= 2
assert not context.adapter._message_history.messages
@given("a memory service limited to {count:d} messages")
def step_memory_service_with_limit(context: Any, count: int) -> None:
"""Create memory service with max message window."""
context.memory_service = MemoryService(
session_id="limited-session", max_messages=count
)
@when("I add five interactions via save context")
def step_add_multiple_interactions(context: Any) -> None:
"""Add several interactions to trigger pruning."""
for idx in range(5):
context.memory_service.save_context(
{"input": f"user {idx}"},
{"output": f"ai {idx}"},
)
context.final_messages = context.memory_service.get_messages()
@when("I ask for the two most recent messages")
def step_recent_two(context: Any) -> None:
"""Fetch limited recent messages."""
context.recent_two = context.memory_service.get_recent_messages(2)
@when("I ask for more messages than available")
def step_recent_all(context: Any) -> None:
"""Fetch more messages than stored to exercise branch."""
context.recent_all = context.memory_service.get_recent_messages(10)
@then("the service keeps only the three latest messages")
def step_verify_pruned_messages(context: Any) -> None:
"""Ensure pruning retained the configured window."""
assert len(context.final_messages) == 3
contents = [msg.content for msg in context.final_messages]
assert contents[-1] == "ai 4"
assert "user 4" in contents[-2]
@then("the recent message requests respect their sizes")
def step_verify_recent_requests(context: Any) -> None:
"""Validate recent message helpers."""
assert len(context.recent_two) == 2
assert len(context.recent_all) == len(context.final_messages)
assert [msg.content for msg in context.recent_two] == ["user 4", "ai 4"]
@then("the service summary honors a 20 character limit")
def step_verify_summary_limit(context: Any) -> None:
"""Confirm summary respects the provided character cap."""
summary = context.memory_service.get_summary(max_chars=20)
context.summary = summary
lines = summary.splitlines()
assert 1 <= len(lines) <= 2
assert lines[-1].endswith("ai 4")
@then("memory variables expose history and counts")
def step_verify_memory_variables(context: Any) -> None:
"""Check memory variables include history, summary, and count."""
variables = context.memory_service.get_memory_variables()
assert variables["message_count"] == len(context.final_messages)
assert variables["chat_history"] == context.final_messages
assert isinstance(variables["chat_summary"], str)
assert context.final_messages[-1].content in variables["chat_summary"]
if context.summary:
assert (
context.summary.splitlines()[-1].split(":", 1)[-1].strip()
in variables["chat_summary"]
)
@given("a memory service with default settings")
def step_memory_service_default(context: Any) -> None:
"""Create default memory service without max limit."""
context.memory_service = MemoryService(session_id="default-session")
@when("I add direct message objects to the service")
def step_add_direct_messages(context: Any) -> None:
"""Add BaseMessage instances directly and capture summary/token count."""
context.memory_service.add_message(
HumanMessage(content="Alpha conversation segment")
)
context.memory_service.add_message(AIMessage(content="Beta response text"))
context.summary_before_clear = context.memory_service.get_summary()
context.token_count_before_clear = context.memory_service.get_token_count()
context.messages_before_clear = context.memory_service.get_messages().copy()
@when("I inspect the default conversation adapter")
def step_inspect_default_adapter(context: Any) -> None:
"""Grab default adapter payload."""
context.default_adapter = context.memory_service.conversation_memory
context.default_payload = context.default_adapter.load_memory_variables()
@when("I reset the max message limit to 1")
def step_reset_max_messages(context: Any) -> None:
"""Reduce max message count and trigger immediate pruning."""
context.memory_service.set_max_messages(1)
context.post_set_messages = list(context.memory_service.get_messages())
@when("I save a new interaction to enforce pruning")
def step_save_interaction_after_limit(context: Any) -> None:
"""Save interaction that should prune to configured limit."""
context.memory_service.save_context(
{"input": "latest user"},
{"output": "latest ai"},
)
context.pruned_messages = context.memory_service.get_messages()
@when("I clear the service memory")
def step_clear_service(context: Any) -> None:
"""Clear the memory service history."""
context.memory_service.clear()
@then("the service computed token counts and summaries before clearing")
def step_validate_pre_clear_metrics(context: Any) -> None:
"""Ensure summary and token counts were recorded before clearing."""
assert context.summary_before_clear
assert context.token_count_before_clear > 0
payload = context.default_payload[context.default_adapter.memory_key]
assert isinstance(payload, list)
assert len(context.post_set_messages) == 1
@then("the service history is empty after clearing")
def step_validate_cleared_history(context: Any) -> None:
"""History should be empty after clear."""
assert not context.memory_service.get_messages()
@then("a custom conversation adapter returns text values")
def step_custom_adapter_returns_text(context: Any) -> None:
"""Create custom adapter and ensure it returns string payloads."""
custom_adapter = context.memory_service.create_conversation_memory(
memory_key="custom_history",
input_key="prompt",
output_key="reply",
return_messages=False,
)
payload = custom_adapter.load_memory_variables()
assert payload["custom_history"] == ""
@given("a patched SQL chat history for memory service")
def step_patch_sql_history(context: Any) -> None:
"""Patch SQLChatMessageHistory to observe construction."""
context.sql_history_calls = []
context.sql_history_instance = InMemoryChatMessageHistory()
context.sql_temp_dir = tempfile.mkdtemp()
def factory(*args: Any, **kwargs: Any) -> InMemoryChatMessageHistory:
context.sql_history_calls.append((args, kwargs))
return context.sql_history_instance
sql_patch = patch(
"cleveragents.application.services.memory_service.SQLChatMessageHistory",
side_effect=factory,
)
context.sql_patch = sql_patch
sql_patch.start()
if hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers.append(sql_patch.stop)
context._cleanup_handlers.append(
lambda: shutil.rmtree(context.sql_temp_dir, ignore_errors=True)
)
@when("I create a memory service with a connection string")
def step_create_sql_memory_service(context: Any) -> None:
"""Instantiate memory service pointing at SQL backend."""
db_path = Path(context.sql_temp_dir) / "memory.db"
context.connection_string = f"sqlite:///{db_path}"
context.sql_service = MemoryService(
session_id="sql-session",
connection_string=context.connection_string,
)
@then("the SQL history constructor receives the session and connection")
def step_validate_sql_constructor(context: Any) -> None:
"""Verify the patched SQL history constructor was invoked correctly."""
assert context.sql_history_calls
_, kwargs = context.sql_history_calls[0]
assert kwargs["session_id"] == "sql-session"
assert kwargs["connection_string"] == context.connection_string
assert kwargs.get("table_name") == "message_history"
@then("stored messages flow through the patched SQL history")
def step_validate_sql_storage(context: Any) -> None:
"""Ensure messages saved through the service reach the patched history."""
context.sql_service.save_context(
{"input": "persisted human"},
{"output": "persisted ai"},
)
messages = context.sql_history_instance.messages
assert len(messages) == 2
assert isinstance(messages[0], HumanMessage)
assert isinstance(messages[1], AIMessage)
assert messages[0].content == "persisted human"
assert messages[1].content == "persisted ai"
+164
View File
@@ -10,6 +10,10 @@ from behave.runner import Context
from sqlalchemy import create_engine
from cleveragents.application.services.plan_service import PlanService
from cleveragents.application.services.memory_service import (
ConversationBufferMemoryAdapter,
MemoryService,
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core import (
@@ -808,3 +812,163 @@ def step_check_no_applied_in_pending(context: Context) -> None:
assert not change.applied
assert "pending" in change.file_path
assert "applied" not in change.file_path
def _ensure_memory_database_url(context: Context) -> None:
"""Ensure plan service uses an in-memory database for memory tests."""
try:
if context.plan_service.settings.database_url != "sqlite:///:memory:":
context.plan_service.settings.database_url = "sqlite:///:memory:"
except AttributeError:
context.plan_service.settings = Settings(database_url="sqlite:///:memory:")
def _request_memory_service_for_session(
context: Context,
session_id: str,
persistent: bool,
max_messages: int | None,
) -> MemoryService:
if persistent:
_ensure_memory_database_url(context)
service = context.plan_service.get_memory_service(
session_id,
persistent=persistent,
max_messages=max_messages,
)
context.last_memory_service = service
context.last_memory_session = session_id
return service
@when(
'I request an in-memory memory service for session "{session_id}" with max messages {max_messages:d}'
)
def step_request_in_memory_memory_service(
context: Context, session_id: str, max_messages: int
) -> None:
service = _request_memory_service_for_session(
context, session_id, persistent=False, max_messages=max_messages
)
assert isinstance(service, MemoryService)
@when(
'I request a persistent memory service for session "{session_id}" with max messages {max_messages:d}'
)
def step_request_persistent_memory_service(
context: Context, session_id: str, max_messages: int
) -> None:
service = _request_memory_service_for_session(
context, session_id, persistent=True, max_messages=max_messages
)
assert isinstance(service, MemoryService)
@when(
'I request an in-memory memory service for session "{session_id}" without changing message limit'
)
def step_request_in_memory_without_limit(context: Context, session_id: str) -> None:
service = _request_memory_service_for_session(
context, session_id, persistent=False, max_messages=None
)
assert isinstance(service, MemoryService)
@then(
'the session "{session_id}" memory should be in-memory with max messages {max_messages:d}'
)
def step_verify_in_memory_with_max(
context: Context, session_id: str, max_messages: int
) -> None:
service = context.plan_service._memory_services.get(session_id)
assert service is not None
assert isinstance(service, MemoryService)
assert service.connection_string is None
assert service.max_messages == max_messages
context.last_memory_service = service
@then(
'the session "{session_id}" memory should be persistent with max messages {max_messages:d}'
)
def step_verify_persistent_with_max(
context: Context, session_id: str, max_messages: int
) -> None:
service = context.plan_service._memory_services.get(session_id)
assert service is not None
assert isinstance(service, MemoryService)
assert service.connection_string == context.plan_service.settings.database_url
assert service.max_messages == max_messages
context.last_memory_service = service
@then(
'the session "{session_id}" memory should be in-memory without a connection string'
)
def step_verify_in_memory_without_connection(context: Context, session_id: str) -> None:
service = context.plan_service._memory_services.get(session_id)
assert service is not None
assert isinstance(service, MemoryService)
assert service.connection_string is None
@when('I request conversation memory for session "{session_id}" with custom keys')
def step_request_conversation_memory_custom(context: Context, session_id: str) -> None:
adapter = context.plan_service.get_conversation_memory(
session_id,
persistent=False,
memory_key="chat_history",
input_key="prompt",
output_key="response",
return_messages=False,
max_messages=4,
)
context.conversation_adapter = adapter
context.last_memory_session = session_id
@then("the conversation adapter should expose the custom keys")
def step_verify_conversation_adapter_keys(context: Context) -> None:
adapter = getattr(context, "conversation_adapter", None)
assert isinstance(adapter, ConversationBufferMemoryAdapter)
assert adapter.memory_variables == ["chat_history"]
assert adapter.input_key == "prompt"
assert adapter.output_key == "response"
assert adapter.return_messages is False
@then("the conversation adapter should share the existing memory service")
def step_verify_conversation_adapter_service(context: Context) -> None:
session_id = getattr(context, "last_memory_session", None)
assert session_id is not None
service = context.plan_service._memory_services.get(session_id)
assert service is not None
adapter = context.conversation_adapter
assert adapter._message_history is service.message_history # type: ignore[attr-defined]
@given('I stored a chat message in session "{session_id}"')
def step_store_chat_message(context: Context, session_id: str) -> None:
service = _request_memory_service_for_session(
context, session_id, persistent=False, max_messages=None
)
service.add_user_message("hello memory branch")
assert len(service.message_history.messages) == 1
context.stored_memory_session = session_id
context.stored_memory_service = service
@when('I clear the session "{session_id}" memory with forget history')
def step_clear_memory_with_forget(context: Context, session_id: str) -> None:
assert getattr(context, "stored_memory_session", None) == session_id
context.plan_service.clear_memory(session_id, forget_history=True)
context.cleared_memory_service = context.stored_memory_service
@then('the session "{session_id}" memory should be removed and emptied')
def step_verify_memory_cleared(context: Context, session_id: str) -> None:
assert session_id not in context.plan_service._memory_services
service = getattr(context, "cleared_memory_service", None)
assert isinstance(service, MemoryService)
assert service.message_history.messages == []
+496
View File
@@ -1,7 +1,9 @@
"""Step definitions for retry patterns feature tests."""
import asyncio
import logging
import time
from types import SimpleNamespace
from behave import given, then, when
@@ -15,12 +17,19 @@ from cleveragents.core.retry_patterns import (
CircuitBreakerOpen,
RetryContext,
async_retry_with_exponential_backoff,
configure_retry_logging,
get_retry_decorator,
log_after_retry,
log_before_retry,
retry_auto_debug,
retry_file_operation,
retry_network_operation,
retry_provider_operation,
retry_with_exponential_backoff,
retry_with_jitter,
should_retry_result,
)
from cleveragents.core import retry_patterns as retry_patterns_module
@given("I have the retry patterns module imported")
@@ -217,6 +226,51 @@ def step_verify_rate_limit_backoff(context):
assert context.call_count >= 2, "Should have retried at least once"
# File operation retry pattern tests
@given("I have a file operation that fails with OSError once")
def step_create_file_operation(context):
"""Create a file operation that fails once before succeeding."""
context.file_call_count = 0
def file_operation():
context.file_call_count += 1
if context.file_call_count == 1:
raise OSError("transient file failure")
return "file success"
context.file_operation = file_operation
@when("I apply file retry pattern")
def step_apply_file_retry(context):
"""Apply the file retry decorator to the operation."""
decorated_func = retry_file_operation(context.file_operation)
try:
context.file_result = decorated_func()
context.file_retry_succeeded = True
except Exception as exc: # pragma: no cover - defensive branch
context.file_retry_error = exc
context.file_retry_succeeded = False
@then("the file operation should eventually succeed")
def step_verify_file_retry_success(context):
"""Verify the file operation eventually succeeds."""
assert context.file_retry_succeeded, (
f"File retry failed: {getattr(context, 'file_retry_error', 'Unknown error')}"
)
assert context.file_result == "file success"
@then("the file operation should be invoked {expected:d} times")
def step_verify_file_retry_invocations(context, expected):
"""Verify the file operation was invoked the expected number of times."""
assert context.file_call_count == expected, (
f"Expected {expected} calls, got {context.file_call_count}"
)
# Circuit breaker tests
@given("I have a circuit breaker with threshold {threshold:d}")
def step_create_circuit_breaker(context, threshold):
@@ -361,6 +415,66 @@ def step_verify_errors_recorded(context):
assert len(context.recorded_errors) >= 2
@when("I execute a failing block under the retry context manager")
def step_retry_context_manager_failure(context):
"""Execute a failing block under the retry context manager."""
errors_before = len(context.retry_context.errors)
try:
with context.retry_context:
raise RuntimeError("context manager failure")
except RuntimeError:
context.retry_context_manager_error = True
else: # pragma: no cover - defensive branch
context.retry_context_manager_error = False
context.retry_context_errors_added_sync = (
len(context.retry_context.errors) - errors_before
)
@then("the retry context manager should capture the exception")
def step_verify_retry_context_manager_capture(context):
"""Ensure the retry context manager captured the exception."""
assert context.retry_context_manager_error, "Expected runtime error to propagate"
assert context.retry_context_errors_added_sync >= 1
assert isinstance(context.retry_context.errors[-1], Exception)
@when("I execute a failing block under the async retry context manager")
def step_async_retry_context_manager_failure(context):
"""Execute a failing block under the async retry context manager."""
errors_before = len(context.retry_context.errors)
async def run_async_block():
try:
async with context.retry_context:
raise RuntimeError("async context failure")
except RuntimeError:
return True
return False
loop = asyncio.new_event_loop()
try:
context.async_retry_context_manager_error = loop.run_until_complete(
run_async_block()
)
finally:
loop.close()
context.retry_context_errors_added_async = (
len(context.retry_context.errors) - errors_before
)
@then("the async retry context manager should capture the exception")
def step_verify_async_retry_context_manager_capture(context):
"""Ensure the async retry context manager captured the exception."""
assert context.async_retry_context_manager_error, (
"Expected async runtime error to propagate"
)
assert context.retry_context_errors_added_async >= 1
assert isinstance(context.retry_context.errors[-1], Exception)
# Retry with jitter tests
@given("I have multiple concurrent operations")
def step_setup_concurrent_operations(context):
@@ -510,3 +624,385 @@ def step_verify_database_retry_count(context, expected):
def step_verify_file_retry_count(context, expected):
"""Verify file operation retry configuration."""
assert context.categories["file_operation"]["max_attempts"] == expected
@given("the retry logger rejects keyword arguments")
def step_retry_logger_rejects_keyword_arguments(context):
"""Configure a logger that rejects keyword arguments."""
context.logged_messages = []
class KeywordRejectingLogger:
def __init__(self, messages):
self.messages = messages
def debug(self, message, *args, **kwargs):
if kwargs:
raise TypeError("Keyword arguments not supported")
self.messages.append(message)
def info(self, *args, **kwargs):
return None
def warning(self, *args, **kwargs):
return None
def error(self, *args, **kwargs):
return None
context.original_logger = retry_patterns_module.logger
retry_patterns_module.logger = KeywordRejectingLogger(context.logged_messages)
def cleanup_logger():
retry_patterns_module.logger = context.original_logger
context.add_cleanup(cleanup_logger)
@when("I invoke logging callbacks for failed and successful attempts")
def step_invoke_logging_callbacks(context):
"""Trigger logging callbacks to exercise fallback paths."""
failure_state = SimpleNamespace(
attempt_number=1,
outcome=SimpleNamespace(
failed=True,
exception=lambda: ValueError("simulated failure"),
),
next_action=SimpleNamespace(sleep=0.5),
)
success_state = SimpleNamespace(
attempt_number=2,
outcome=SimpleNamespace(failed=False),
next_action=None,
)
log_before_retry(failure_state)
log_after_retry(failure_state)
log_after_retry(success_state)
@then("the fallback logging messages should be recorded")
def step_verify_fallback_logging_messages(context):
"""Verify fallback logging produced the expected messages."""
expected_messages = {
"Starting attempt 1",
"Attempt 1 failed, will retry",
"Attempt 2 succeeded",
}
recorded = set(context.logged_messages)
missing = expected_messages - recorded
assert not missing, f"Missing fallback messages: {missing}"
@given("I have a half-open circuit breaker with threshold {threshold:d}")
def step_half_open_circuit_breaker(context, threshold):
"""Prepare a circuit breaker in half-open state."""
context.circuit_breaker = CircuitBreaker(
failure_threshold=threshold,
recovery_timeout=0.1,
expected_exception=Exception,
)
context.circuit_breaker.state = "half-open"
context.circuit_breaker.success_count_half_open = 0
context.circuit_breaker.failure_count = 0
@when("I record a successful half-open call")
def step_record_half_open_success(context):
"""Record the first successful call while half-open."""
context.last_half_open_result = context.circuit_breaker.call(
lambda: "half-open success"
)
@when("I record another successful half-open call")
def step_record_second_half_open_success(context):
"""Record the second successful call while half-open."""
context.second_half_open_result = context.circuit_breaker.call(
lambda: "half-open success"
)
@then("the circuit breaker should close after consecutive successes")
def step_verify_circuit_closes_after_successes(context):
"""Ensure the circuit closes after consecutive successes."""
assert context.circuit_breaker.state == "closed", "Circuit should be closed"
assert context.circuit_breaker.success_count_half_open == 0
assert context.second_half_open_result == "half-open success"
@when("I simulate a half-open failure")
def step_simulate_half_open_failure(context):
"""Simulate a failure while half-open to reopen the circuit."""
context.circuit_breaker.state = "half-open"
context.circuit_breaker.success_count_half_open = 0
def failing_call():
raise Exception("half-open failure")
try:
context.circuit_breaker.call(failing_call)
except Exception:
context.half_open_failure_caught = True
else:
context.half_open_failure_caught = False
@then("the circuit breaker should reopen after half-open failure")
def step_verify_circuit_reopens_after_failure(context):
"""Verify the circuit reopens after a half-open failure."""
assert context.half_open_failure_caught, "Failure should have been raised"
assert context.circuit_breaker.state == "open"
assert context.circuit_breaker.success_count_half_open == 0
@given("I have an async circuit breaker with threshold {threshold:d}")
def step_create_async_circuit_breaker(context, threshold):
"""Create a circuit breaker for async operations."""
context.circuit_breaker = CircuitBreaker(
failure_threshold=threshold,
recovery_timeout=0.2,
expected_exception=Exception,
)
context.async_loop = asyncio.new_event_loop()
context.add_cleanup(context.async_loop.close)
@when("I trigger an async failure on the circuit breaker")
def step_trigger_async_failure(context):
"""Trigger an async failure to open the circuit."""
async def failing_call():
raise Exception("async breaker failure")
async def run_failure():
try:
await context.circuit_breaker.async_call(failing_call)
except Exception:
return True
return False
context.async_failure_caught = context.async_loop.run_until_complete(run_failure())
assert context.async_failure_caught, "Async failure should have been caught"
assert context.circuit_breaker.state == "open"
@then("the async circuit breaker should raise immediately while open")
def step_verify_async_open_state(context):
"""Ensure async calls are blocked while circuit remains open."""
async def success_call():
return "async success"
async def run_open_call():
try:
await context.circuit_breaker.async_call(success_call)
except CircuitBreakerOpen:
return True
return False
context.async_open_blocked = context.async_loop.run_until_complete(run_open_call())
assert context.async_open_blocked, "Circuit breaker should prevent async call"
@when("I perform two async successful calls after recovery")
def step_async_success_calls(context):
"""Execute two successful async calls after recovery."""
async def success_call():
return "async success"
async def run_success_calls():
first = await context.circuit_breaker.async_call(success_call)
second = await context.circuit_breaker.async_call(success_call)
return first, second
context.async_success_results = context.async_loop.run_until_complete(
run_success_calls()
)
@then("the async circuit breaker should close after async successes")
def step_verify_async_circuit_closed(context):
"""Verify the circuit closes after successful async calls."""
assert context.async_success_results == ("async success", "async success")
assert context.circuit_breaker.state == "closed"
@given('I have a response payload with error "{message}"')
def step_response_payload_with_error(context, message):
"""Create a response payload containing an error message."""
context.result_payload = {"error": message}
@given("I have a response payload with status code {status:d}")
def step_response_payload_with_status(context, status):
"""Create a response payload containing a status code."""
context.result_payload = {"status_code": status}
@given("I have a result payload set to None")
def step_result_payload_none(context):
"""Set the result payload to None."""
context.result_payload = None
@given('I have a result payload set to the string "{value}"')
def step_result_payload_string(context, value):
"""Set the result payload to a literal string value."""
context.result_payload = value
@when("I evaluate the retry decision")
def step_evaluate_retry_decision(context):
"""Evaluate whether the result should trigger a retry."""
context.retry_decision = should_retry_result(context.result_payload)
@then("the retry decision should be True")
def step_verify_retry_decision_true(context):
"""Confirm the retry decision is positive."""
assert context.retry_decision is True
@then("the retry decision should be False")
def step_verify_retry_decision_false(context):
"""Confirm the retry decision is negative."""
assert context.retry_decision is False
@when('I request a retry decorator for "{category}"')
def step_request_retry_decorator(context, category):
"""Request a retry decorator for the given category."""
context.retry_decorator = get_retry_decorator(category)
context.decorated_value = None
@when('I decorate a function returning "{value}"')
def step_decorate_function_with_retry(context, value):
"""Decorate a function and execute it to capture the result."""
def sample_function():
return value
decorated_function = context.retry_decorator(sample_function)
context.decorated_value = value
context.decorated_result = decorated_function()
@then("the decorated function should execute successfully")
def step_verify_decorated_function_execution(context):
"""Ensure the decorated function returns the expected value."""
assert context.decorated_result == context.decorated_value
@when("I configure retry logging to {level_name} level")
def step_configure_retry_logging_level(context, level_name):
"""Configure retry logging and capture existing levels for restoration."""
level_attr = level_name.upper()
assert hasattr(logging, level_attr), f"Unknown log level: {level_name}"
level_value = getattr(logging, level_attr)
before_logger = logging.getLogger("tenacity.before")
after_logger = logging.getLogger("tenacity.after")
context.original_tenacity_levels = (before_logger.level, after_logger.level)
def restore_log_levels(): # pragma: no cover - cleanup path
before_logger.setLevel(context.original_tenacity_levels[0])
after_logger.setLevel(context.original_tenacity_levels[1])
context.add_cleanup(restore_log_levels)
configure_retry_logging(level_value)
context.configured_log_level = level_value
@then("the tenacity loggers should be set to {level_name}")
def step_verify_tenacity_log_levels(context, level_name):
"""Verify tenacity loggers were set to the expected log level."""
expected_level = getattr(logging, level_name.upper())
before_level = logging.getLogger("tenacity.before").level
after_level = logging.getLogger("tenacity.after").level
assert before_level == expected_level
assert after_level == expected_level
assert context.configured_log_level == expected_level
@given("I have a function that returns persistent error payloads")
def step_function_with_persistent_errors(context):
"""Create a function that always returns error payloads."""
context.persistent_error_count = 0
async def persistent_error_function():
context.persistent_error_count += 1
return {"error": f"Persistent failure {context.persistent_error_count}"}
context.test_function = persistent_error_function
@given("I have a debug callback that never fixes issues")
def step_debug_callback_never_fixes(context):
"""Create a debug callback that never fixes the issue."""
async def never_fix(_error, _attempt):
return {"fixed": False}
context.debug_callback = never_fix
@given("I have a debug callback that raises errors")
def step_debug_callback_raises_errors(context):
"""Create a debug callback that raises when invoked."""
context.debug_callback_failure_count = 0
async def raising_callback(_error, _attempt):
context.debug_callback_failure_count += 1
raise RuntimeError("debug callback failure")
context.debug_callback = raising_callback
@when("I apply auto-debug retry expecting failure")
def step_apply_auto_debug_retry_failure(context):
"""Run auto-debug retry expecting it to raise the last error message."""
decorated_func = retry_auto_debug(
max_debug_attempts=3, debug_callback=context.debug_callback
)(context.test_function)
async def run_auto_debug():
try:
result = await decorated_func()
return result, True
except Exception as exc: # pragma: no cover - defensive branch
return exc, False
loop = asyncio.new_event_loop()
try:
result, succeeded = loop.run_until_complete(run_auto_debug())
finally:
loop.close()
context.debug_result = result
context.debug_succeeded = succeeded
context.expected_last_error = f"Persistent failure {context.persistent_error_count}"
@then("the auto-debug retry should raise the last error message")
def step_verify_auto_debug_raises_last_error(context):
"""Ensure the auto-debug retry raises the final error string."""
assert not context.debug_succeeded, "Auto-debug retry unexpectedly succeeded"
assert isinstance(context.debug_result, Exception)
assert str(context.debug_result) == context.expected_last_error
@then("the auto-debug retry should raise the debug callback failure")
def step_verify_auto_debug_raises_callback_failure(context):
"""Ensure the debug callback failure bubble is surfaced."""
assert not context.debug_succeeded, "Auto-debug retry unexpectedly succeeded"
assert isinstance(context.debug_result, Exception)
assert str(context.debug_result) == "debug callback failure"
@then("the debug callback failures should be counted")
def step_verify_debug_callback_failures(context):
"""Ensure the debug callback failure path was exercised."""
failure_count = getattr(context, "debug_callback_failure_count", 0)
assert failure_count >= 1, "Expected debug callback to fail at least once"
+59 -15
View File
@@ -782,19 +782,19 @@ All 14 core commands have been successfully implemented with comprehensive testi
5. ✅ Implement base StateGraph classes for workflow patterns
**Week 11-12: Core Integration**
6. **Implement PlanGenerationGraph** using LangGraph StateGraph:
6. **Implement PlanGenerationGraph** using LangGraph StateGraph:
- Nodes: load_context → analyze_requirements → generate_plan → validate
- Conditional edges for retry logic
- Checkpointing for resumable builds
7. **Add LangChain memory to existing services**:
- ConversationBufferMemory for PlanService
- EntityMemory for ProjectService
- SQLChatMessageHistory for persistence
8. **Create ContextAnalysisAgent** with LangChain:
7. **Add LangChain memory to existing services**:
- Updated MemoryService to use LangChain 1.0+ API (BaseChatMessageHistory, InMemoryChatMessageHistory, SQLChatMessageHistory)
- Removed deprecated ConversationBufferMemory and ConversationSummaryMemory
- Ready for integration with PlanService and other services
8. **Create ContextAnalysisAgent** with LangChain:
- Document loaders for code files
- Semantic chunking strategies
- Relevance scoring for context
9. **Replace manual retry patterns** with LangChain's built-in retry decorators
9. **Replace manual retry patterns** with LangChain's built-in retry decorators
10. **Integrate LangGraph streaming** into CLI commands for real-time feedback
**Week 13: Testing and Documentation**
@@ -860,6 +860,49 @@ All 14 core commands have been successfully implemented with comprehensive testi
- **IMPORTANT**: These retry patterns will remain for non-LLM operations (database, file I/O, custom APIs)
- **LLM operations will use LangChain's built-in retry mechanisms** which are more sophisticated for AI providers
**2025-11-18: Phase 2 Week 9-10 LangChain/LangGraph Integration Progress**
**Completed Tasks (Week 9):**
1. **LangChain/LangGraph Dependencies Installed**
- Added all LangChain ecosystem packages to pyproject.toml under `[project.optional-dependencies.llm]`
- Installed: langchain, langchain-core, langchain-community, langchain-openai, langchain-anthropic, langchain-google-genai, langgraph, langsmith, tiktoken
- All dependencies successfully installed and working
2. **ADR-011 Created**
- Created `docs/architecture/decisions/ADR-011-langchain-langgraph-integration.md`
- Documented integration patterns for LangChain/LangGraph
- Defined graph design principles and state management strategies
- Established provider abstraction approach using LangChain's unified interfaces
3. **Agents Package Structure Created**
- Created `src/cleveragents/agents/` package directory
- Implemented `base.py` with BaseAgent and BaseStateGraph classes
- Provides foundation for all LangGraph-based agent workflows
4. **LangChain Mock Provider Implementation** (IN PROGRESS)
- Created `features/mocks/langchain_mock_provider.py` using LangChain's FakeListLLM
- Replaces the original MockAIProvider with LangChain-based implementation
- Provides deterministic testing with LangChain's testing utilities
- Successfully loads and ready for integration
5. **Conversation Memory Foundation**
- Added `ConversationBufferMemoryAdapter` to `memory_service.py` to mirror LangChain's buffer behavior while supporting SQL and in-memory histories
- Exposed `conversation_memory`, `create_conversation_memory`, and `set_max_messages` helpers on the memory service for prompts and runnables
- Extended `plan_service.py` with `get_memory_service`, `get_conversation_memory`, and `clear_memory` helpers to manage per-session conversational state backed by settings-aware persistence
**Next Immediate Tasks (Week 10):**
1. Update existing services to use LangChainMockProvider
2. Implement PlanGenerationGraph using LangGraph StateGraph
3. Add LangChain memory systems to services (ConversationBufferMemory ✅, EntityMemory ➡️)
4. Create ContextAnalysisAgent with document loaders
5. Update tests for LangChain compatibility
**Technical Notes:**
- LangChain packages require Python 3.9+ (we're using 3.13)
- FakeListLLM provides deterministic responses for testing
- LangGraph requires defining state schemas using TypedDict
- All LangChain providers will use the unified BaseLanguageModel interface
**Phase 2 Planning Updates (Based on Lessons from Phase 0 & 1):**
**KEY INSIGHT: Start Simple, Iterate Quickly**
@@ -1712,10 +1755,10 @@ class TestLLMProvider(FakeListLLM):
- Implement retry logic with conditional edges
- Add streaming for real-time progress
2. **Add Memory to Services**:
- Integrate ConversationBufferMemory
- Add EntityMemory for tracking
- Implement SQLChatMessageHistory
2. **Add Memory to Services** *(in progress)*:
- Integrate ConversationBufferMemory (completed 2025-11-18 via `memory_service.py` & `plan_service.py` updates)
- ◻️ Add EntityMemory for tracking
- Implement SQLChatMessageHistory (previously delivered in `memory_service.py`)
#### Week 12 Tasks:
1. **Create ContextAnalysisAgent**:
@@ -2976,10 +3019,11 @@ Each phase item includes mandatory **Code**, **Document**, and **Tests** bullets
- [ ] Fix generation node
- [ ] Verification node
- [ ] Conditional retry edges
- [ ] Memory Integration
- [ ] Add ConversationBufferMemory to PlanService
- [ ] Add EntityMemory for project tracking
- [ ] Implement SQLChatMessageHistory for persistence
- [ ] Memory Integration
- [x] Add ConversationBufferMemory to PlanService
- [ ] Add EntityMemory for project tracking
- [x] Implement SQLChatMessageHistory for persistence
- [ ] Add vector store for semantic search (optional)
- [ ] Streaming and Real-time
- [ ] Integrate LangGraph streaming events
+1
View File
@@ -52,6 +52,7 @@ dependencies = [
"langchain-community>=0.3.0",
"langgraph>=0.2.0",
"langgraph-checkpoint>=2.0.0",
"langsmith>=0.2.0", # LangSmith observability
# Additional AI/ML utilities
"tiktoken>=0.7.0", # Token counting for OpenAI models
"httpx>=0.27.0", # Better async HTTP client for API calls
+21
View File
@@ -0,0 +1,21 @@
"""
CleverAgents LangGraph-based agent workflows.
This package contains all agent implementations using LangGraph for stateful
workflow orchestration and LangChain for LLM integration.
"""
from .base import BaseAgent, BaseStateGraph
from .graphs import (
PlanGenerationGraph,
ContextAnalysisGraph,
AutoDebugGraph,
)
__all__ = [
"BaseAgent",
"BaseStateGraph",
"PlanGenerationGraph",
"ContextAnalysisGraph",
"AutoDebugGraph",
]
+136
View File
@@ -0,0 +1,136 @@
"""
Base classes for LangGraph-based agents.
This module provides the foundation for all agent implementations
using LangGraph for workflow orchestration.
"""
from typing import Any, TypedDict
from langchain_core.language_models import BaseLanguageModel
from langgraph.graph import StateGraph
class BaseStateGraph(TypedDict):
"""Base state structure for LangGraph workflows."""
messages: list[dict[str, Any]]
context: dict[str, Any]
result: dict[str, Any]
error: str | None
class BaseAgent:
"""Base class for all CleverAgents agents."""
def __init__(
self,
provider: str = "openai",
model: str | None = None,
temperature: float = 0.7,
**provider_kwargs: Any,
):
"""Initialize the base agent.
Args:
provider: LLM provider name
model: Model identifier
temperature: Temperature for generation
**provider_kwargs: Additional provider-specific arguments
"""
self.provider = provider
self.model = model
self.temperature = temperature
self.provider_kwargs = provider_kwargs
# Initialize components
self.llm = self._create_llm()
self.memory = self._create_memory()
self.graph = self._build_graph()
self.app = self.graph.compile(checkpointer=self.memory)
def _create_llm(self) -> BaseLanguageModel:
"""Create the LLM instance based on provider configuration.
Returns:
Configured language model instance
"""
# This will be implemented with actual provider logic
# For now, return a mock
from langchain_community.llms import FakeListLLM
return FakeListLLM(responses=["Mock response"], sleep=0.1)
def _create_memory(self) -> Any:
"""Create memory/checkpointer for the agent.
Returns:
Memory instance for state persistence
"""
from langgraph.checkpoint.memory import MemorySaver
return MemorySaver()
def _build_graph(self) -> StateGraph:
"""Build the state graph for the agent workflow.
Returns:
Compiled state graph
Note:
Subclasses should override this method to define
their specific workflow logic.
"""
raise NotImplementedError("Subclasses must implement _build_graph")
def invoke(
self, input_data: dict[str, Any], config: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Execute the agent workflow synchronously.
Args:
input_data: Input data for the workflow
config: Optional configuration for execution
Returns:
Workflow execution result
"""
if config is None:
config = {"configurable": {"thread_id": "default"}}
result = self.app.invoke(input_data, config)
return result
async def ainvoke(
self, input_data: dict[str, Any], config: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Execute the agent workflow asynchronously.
Args:
input_data: Input data for the workflow
config: Optional configuration for execution
Returns:
Workflow execution result
"""
if config is None:
config = {"configurable": {"thread_id": "default"}}
result = await self.app.ainvoke(input_data, config)
return result
def stream(self, input_data: dict[str, Any], config: dict[str, Any] | None = None):
"""Stream the agent workflow execution.
Args:
input_data: Input data for the workflow
config: Optional configuration for execution
Yields:
Streaming workflow events
"""
if config is None:
config = {"configurable": {"thread_id": "default"}}
for event in self.app.stream(input_data, config):
yield event
@@ -0,0 +1,334 @@
"""Memory service for managing conversation history with LangChain.
This service provides memory management for AI conversations using
LangChain's message history abstractions (LangChain 1.0+ API).
Provides ConversationBufferMemory-style interface for managing conversation
history with support for multiple storage backends.
"""
from collections.abc import Callable, Sequence
from typing import Any, Optional
from langchain_community.chat_message_histories import SQLChatMessageHistory
from langchain_core.chat_history import (
BaseChatMessageHistory,
InMemoryChatMessageHistory,
get_buffer_string,
)
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
class ConversationBufferMemoryAdapter:
"""Lightweight adapter that mirrors LangChain's ConversationBufferMemory.
This adapter provides a familiar interface for prompt templates and
runnables that expect a conversational memory object while leveraging the
configured `BaseChatMessageHistory` implementation under the hood.
"""
def __init__(
self,
message_history: BaseChatMessageHistory,
*,
memory_key: str = "history",
input_key: str = "input",
output_key: str = "output",
return_messages: bool = True,
prune_callback: Callable[[], None] | None = None,
) -> None:
self._message_history = message_history
self.memory_key = memory_key
self.input_key = input_key
self.output_key = output_key
self.return_messages = return_messages
self._prune_callback = prune_callback
@property
def memory_variables(self) -> list[str]:
"""Return the keys that this memory provides."""
return [self.memory_key]
def load_memory_variables(
self, _inputs: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Return the current memory payload for prompt formatting."""
messages = self._message_history.messages
if self.return_messages:
value: Any = messages
else:
value = get_buffer_string(messages)
return {self.memory_key: value}
async def aload_memory_variables(
self, inputs: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Async variant mirroring the sync load method."""
return self.load_memory_variables(inputs)
def save_context(self, inputs: dict[str, Any], outputs: dict[str, Any]) -> None:
"""Persist a complete interaction into the underlying history."""
def _store_user(value: Any) -> None:
if value is None:
return
if isinstance(value, BaseMessage):
self._message_history.add_message(value)
return
if isinstance(value, Sequence) and not isinstance(
value, (str, bytes, bytearray)
):
for item in value:
_store_user(item)
return
self._message_history.add_user_message(str(value))
def _store_ai(value: Any) -> None:
if value is None:
return
if isinstance(value, BaseMessage):
self._message_history.add_message(value)
return
if isinstance(value, Sequence) and not isinstance(
value, (str, bytes, bytearray)
):
for item in value:
_store_ai(item)
return
self._message_history.add_ai_message(str(value))
_store_user(inputs.get(self.input_key))
_store_ai(outputs.get(self.output_key))
if self._prune_callback:
self._prune_callback()
async def asave_context(
self, inputs: dict[str, Any], outputs: dict[str, Any]
) -> None:
"""Async variant mirroring the sync save method."""
self.save_context(inputs, outputs)
def clear(self) -> None:
"""Clear the stored conversation history."""
self._message_history.clear()
if self._prune_callback:
self._prune_callback()
async def aclear(self) -> None:
"""Async variant of ``clear``."""
self.clear()
class MemoryService:
"""Service for managing conversation memory using LangChain 1.0+.
Provides different memory strategies:
- In-memory: Keeps conversation history in memory
- SQL persistence: Stores messages in database
- Summarization: Maintains summarized conversation (via custom logic)
"""
def __init__(
self,
session_id: str,
connection_string: Optional[str] = None,
max_messages: Optional[int] = None,
):
"""Initialize memory service.
Args:
session_id: Unique identifier for the conversation session
connection_string: Database connection for SQL persistence (optional)
max_messages: Maximum number of messages to keep in memory (optional)
"""
self.session_id = session_id
self.connection_string = connection_string
self.max_messages = max_messages
# Initialize message history
self.message_history: BaseChatMessageHistory
if connection_string:
# Use SQL persistence if connection string provided
self.message_history = SQLChatMessageHistory(
session_id=session_id,
connection_string=connection_string,
table_name="message_history",
)
else:
# Use in-memory storage as fallback
self.message_history = InMemoryChatMessageHistory()
# Default conversation-memory adapter mirroring LangChain's API.
self._conversation_memory = ConversationBufferMemoryAdapter(
self.message_history,
prune_callback=self._enforce_max_messages,
)
def add_user_message(self, message: str) -> None:
"""Add a user message to memory.
Args:
message: The user's message
"""
self.message_history.add_message(HumanMessage(content=message))
self._enforce_max_messages()
def add_ai_message(self, message: str) -> None:
"""Add an AI response to memory.
Args:
message: The AI's response
"""
self.message_history.add_message(AIMessage(content=message))
self._enforce_max_messages()
def add_message(self, message: BaseMessage) -> None:
"""Add a message object to memory.
Args:
message: The message object to add
"""
self.message_history.add_message(message)
self._enforce_max_messages()
def save_context(self, inputs: dict[str, Any], outputs: dict[str, Any]) -> None:
"""Save a complete interaction to memory.
Args:
inputs: The input variables (expects 'input' key for user message)
outputs: The output variables (expects 'output' key for AI message)
"""
user_message = inputs.get("input", "")
ai_message = outputs.get("output", "")
if user_message:
self.add_user_message(user_message)
if ai_message:
self.add_ai_message(ai_message)
def get_messages(self) -> list[BaseMessage]:
"""Get full conversation history.
Returns:
List of message objects
"""
return self.message_history.messages
def get_recent_messages(self, count: int) -> list[BaseMessage]:
"""Get the most recent N messages.
Args:
count: Number of recent messages to retrieve
Returns:
List of most recent message objects
"""
messages = self.message_history.messages
return messages[-count:] if count < len(messages) else messages
def get_summary(self, max_chars: int = 500) -> str:
"""Get a simple summary of the conversation.
Args:
max_chars: Maximum characters in summary
Returns:
Summary string
"""
messages = self.get_messages()
if not messages:
return ""
# Simple summarization: concatenate recent messages
summary_parts = []
char_count = 0
for msg in reversed(messages):
content_str = str(msg.content)
prefix = "User: " if isinstance(msg, HumanMessage) else "AI: "
msg_text = f"{prefix}{content_str}"
if char_count + len(msg_text) > max_chars:
break
summary_parts.insert(0, msg_text)
char_count += len(msg_text)
return "\n".join(summary_parts)
def get_memory_variables(self) -> dict[str, Any]:
"""Get all memory variables for use in prompts."""
messages = self.get_messages()
return {
"chat_history": messages,
"chat_summary": self.get_summary(),
"message_count": len(messages),
}
@property
def conversation_memory(self) -> ConversationBufferMemoryAdapter:
"""Return the default conversation buffer memory adapter."""
return self._conversation_memory
def create_conversation_memory(
self,
*,
memory_key: str = "history",
input_key: str = "input",
output_key: str = "output",
return_messages: bool = True,
) -> ConversationBufferMemoryAdapter:
"""Create a new conversation memory adapter with custom settings."""
return ConversationBufferMemoryAdapter(
self.message_history,
memory_key=memory_key,
input_key=input_key,
output_key=output_key,
return_messages=return_messages,
prune_callback=self._enforce_max_messages,
)
def set_max_messages(self, max_messages: int | None) -> None:
"""Update the max message window and prune history if needed."""
self.max_messages = max_messages
self._enforce_max_messages()
def clear(self) -> None:
"""Clear all memory."""
self.message_history.clear()
def get_token_count(self) -> int:
"""Get approximate token count of conversation.
Returns:
Estimated token count
"""
# Simple approximation: 1 token ~= 4 characters
messages = self.get_messages()
total_chars = sum(len(str(msg.content)) for msg in messages)
return total_chars // 4
def _enforce_max_messages(self) -> None:
"""Enforce maximum message limit by removing oldest messages."""
if self.max_messages is None:
return
messages = self.message_history.messages
if len(messages) > self.max_messages:
# Remove oldest messages
messages_to_remove = len(messages) - self.max_messages
# Clear and re-add messages (not all implementations support deletion)
remaining_messages = messages[messages_to_remove:]
self.message_history.clear()
for msg in remaining_messages:
self.message_history.add_message(msg)
@@ -5,10 +5,21 @@ contain AI-generated code changes. Uses repository pattern and Unit of Work
for persistence (ADR-007).
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Optional, TYPE_CHECKING
from langchain_core.language_models import BaseLanguageModel
from cleveragents.application.services.memory_service import MemoryService
if TYPE_CHECKING:
from cleveragents.application.services.memory_service import (
ConversationBufferMemoryAdapter,
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core import (
@@ -35,6 +46,7 @@ class PlanService:
settings: Settings,
unit_of_work: UnitOfWork,
ai_provider: AIProviderInterface | None = None,
llm: Optional[BaseLanguageModel] = None,
):
"""Initialize the plan service.
@@ -42,10 +54,86 @@ class PlanService:
settings: Application settings
unit_of_work: Unit of Work for database transactions
ai_provider: AI provider for generating code changes (optional)
llm: Language model for memory service (optional)
"""
self.settings = settings
self.unit_of_work = unit_of_work
self.ai_provider = ai_provider
self._memory_services: dict[str, MemoryService] = {}
self._llm = llm
def get_memory_service(
self,
session_id: str,
*,
persistent: bool = True,
max_messages: int | None = None,
) -> MemoryService:
"""Retrieve or initialize a ``MemoryService`` instance for a session."""
service = self._memory_services.get(session_id)
if service is None:
connection_string = self.settings.database_url if persistent else None
service = MemoryService(
session_id=session_id,
connection_string=connection_string,
max_messages=max_messages,
)
self._memory_services[session_id] = service
return service
# Replace non-persistent memory with persistent one if requested
if persistent and service.connection_string is None:
service = MemoryService(
session_id=session_id,
connection_string=self.settings.database_url,
max_messages=max_messages,
)
self._memory_services[session_id] = service
return service
# Replace persistent memory with in-memory variant when explicitly requested
if not persistent and service.connection_string is not None:
service = MemoryService(session_id=session_id, max_messages=max_messages)
self._memory_services[session_id] = service
return service
if max_messages is not None and service.max_messages != max_messages:
service.set_max_messages(max_messages)
return service
def get_conversation_memory(
self,
session_id: str,
*,
persistent: bool = True,
memory_key: str = "history",
input_key: str = "input",
output_key: str = "output",
return_messages: bool = True,
max_messages: int | None = None,
) -> "ConversationBufferMemoryAdapter":
"""Return a conversation buffer memory adapter for the session."""
service = self.get_memory_service(
session_id,
persistent=persistent,
max_messages=max_messages,
)
return service.create_conversation_memory(
memory_key=memory_key,
input_key=input_key,
output_key=output_key,
return_messages=return_messages,
)
def clear_memory(self, session_id: str, *, forget_history: bool = False) -> None:
"""Remove cached memory service for a session."""
service = self._memory_services.pop(session_id, None)
if service and forget_history:
service.clear()
def create_plan(
self, project: Project, prompt: str, name: str | None = None
+1
View File
@@ -0,0 +1 @@
# Type stubs for langchain_community
@@ -0,0 +1,15 @@
# Type stubs for langchain_community.chat_message_histories
from typing import Any
class SQLChatMessageHistory:
def __init__(
self,
session_id: str,
connection_string: str,
table_name: str = "message_history",
) -> None: ...
@property
def messages(self) -> list[Any]: ...
def add_message(self, message: Any) -> None: ...
def clear(self) -> None: ...