forked from HAL9000/cleveragents-core
Fix: Added streaming to context commands
This commit is contained in:
@@ -6,6 +6,7 @@ Feature: Auto Debug Agent Coverage
|
||||
Background:
|
||||
Given the auto debug agent module is importable
|
||||
And I have a mock LLM provider configured for auto debug
|
||||
And logging is enabled at INFO level for auto debug
|
||||
|
||||
Scenario: AutoDebugAgent can be instantiated with default parameters
|
||||
When I create an AutoDebugAgent with default parameters
|
||||
@@ -339,10 +340,248 @@ Feature: Auto Debug Agent Coverage
|
||||
When I create an AutoDebugAgent with default parameters
|
||||
Then the agent temperature should be 0.3 for auto debug
|
||||
|
||||
Scenario: AutoDebugAgent supports provider kwargs
|
||||
When I create an AutoDebugAgent with provider_kwargs:
|
||||
| kwarg | value |
|
||||
| max_tokens | 2000 |
|
||||
| top_p | 0.9 |
|
||||
Then the agent should be initialized successfully for auto debug
|
||||
And the agent should have provider_kwargs stored
|
||||
|
||||
|
||||
# Coverage for lines 133: analyze_error with real LLM response (not "Mock LLM response")
|
||||
Scenario: Analyze error with non-mock LLM response returns actual content
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a non-mock analysis response "This is a type mismatch error"
|
||||
And I have a state with error details:
|
||||
"""
|
||||
{
|
||||
"error_message": "TypeError: expected int but got str",
|
||||
"code_context": "x = '5' + 5"
|
||||
}
|
||||
"""
|
||||
When I execute the analyze_error step
|
||||
Then the error_analysis content should be "This is a type mismatch error"
|
||||
|
||||
# Coverage for lines 134-136: analyze_error exception handling
|
||||
Scenario: Analyze error handles LLM exception gracefully
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM raises an exception during analysis
|
||||
And I have a state with error details:
|
||||
"""
|
||||
{
|
||||
"error_message": "SyntaxError: invalid syntax",
|
||||
"code_context": "def foo(:"
|
||||
}
|
||||
"""
|
||||
When I execute the analyze_error step
|
||||
Then the error_analysis content should be "Error analysis completed"
|
||||
And a warning should be logged containing "LLM analysis failed"
|
||||
|
||||
# Coverage for lines 223-225: generate_fix with valid JSON response
|
||||
Scenario: Generate fix parses valid JSON from non-mock LLM response
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a valid JSON fix response
|
||||
And I have a state with error analysis completed
|
||||
When I execute the generate_fix step
|
||||
Then the current_fix description should be "Add variable definition"
|
||||
And the current_fix code should be "x = 0; return x + 1"
|
||||
And the current_fix files_to_modify should contain "test.py"
|
||||
|
||||
# Coverage for lines 226-232: generate_fix with invalid JSON response (fallback)
|
||||
Scenario: Generate fix falls back when LLM returns non-JSON response
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a non-JSON fix response "Just add x = 0 before the return"
|
||||
And I have a state with error analysis completed
|
||||
When I execute the generate_fix step
|
||||
Then the current_fix description should contain "Fix attempt"
|
||||
And the current_fix code should be "Just add x = 0 before the return"
|
||||
And the current_fix files_to_modify should be empty
|
||||
|
||||
# Coverage for lines 233-239: generate_fix exception handling
|
||||
Scenario: Generate fix handles LLM exception gracefully
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM raises an exception during fix generation
|
||||
And I have a state with error analysis completed
|
||||
When I execute the generate_fix step
|
||||
Then the current_fix should have a description field
|
||||
And the current_fix should have a code field
|
||||
And a warning should be logged containing "LLM fix generation failed"
|
||||
|
||||
# Coverage for lines 297-299: validate_fix with valid JSON response (is_valid true)
|
||||
Scenario: Validate fix parses valid JSON with is_valid true from non-mock response
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a valid JSON validation response with is_valid true
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Add variable x before use",
|
||||
"code": "x = 0\nreturn x + 1"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be true
|
||||
|
||||
# Coverage for lines 297-299: validate_fix with valid JSON response (is_valid false)
|
||||
Scenario: Validate fix parses valid JSON with is_valid false from non-mock response
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a valid JSON validation response with is_valid false
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Incorrect fix attempt",
|
||||
"code": "return x"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be false
|
||||
|
||||
# Coverage for lines 300-305: validate_fix JSON decode error with positive fallback
|
||||
Scenario: Validate fix falls back to text parsing with positive indicators
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a non-JSON validation response "The fix is valid and correct"
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Add x = 0",
|
||||
"code": "x = 0; return x + 1"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be true
|
||||
|
||||
# Coverage for lines 300-305: validate_fix JSON decode error without positive indicators
|
||||
Scenario: Validate fix falls back to text parsing without positive indicators
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a non-JSON validation response "The code still has issues"
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Broken fix",
|
||||
"code": "return undefined_var"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be false
|
||||
|
||||
# Coverage for lines 306-309: validate_fix exception handling
|
||||
Scenario: Validate fix handles LLM exception gracefully
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM raises an exception during validation
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Some fix",
|
||||
"code": "some code"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be true
|
||||
And a warning should be logged containing "LLM validation failed"
|
||||
|
||||
# Coverage for lines 314-317: tracking failed fix attempts
|
||||
Scenario: Validate fix tracks failed attempts when validation is false
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a valid JSON validation response with is_valid false
|
||||
And I have a state with current fix and empty attempted_fixes:
|
||||
"""
|
||||
{
|
||||
"description": "Failed fix",
|
||||
"code": "bad code"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be false
|
||||
And the attempted_fixes should have 1 entry
|
||||
And the attempted_fixes should contain the current fix
|
||||
|
||||
# Coverage for lines 314-317: multiple failed attempts are tracked
|
||||
Scenario: Validate fix appends to existing attempted_fixes when validation fails
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a valid JSON validation response with is_valid false
|
||||
And I have a state with current fix and one existing attempted fix
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be false
|
||||
And the attempted_fixes should have 2 entries
|
||||
|
||||
# Coverage: validate_fix with "resolves" in response
|
||||
Scenario: Validate fix detects "resolves" keyword in fallback parsing
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a non-JSON validation response "This change resolves the issue"
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Fix that resolves issue",
|
||||
"code": "fixed code"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be true
|
||||
|
||||
# Coverage: validate_fix with "fixes" in response
|
||||
Scenario: Validate fix detects "fixes" keyword in fallback parsing
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a non-JSON validation response "This fixes the problem"
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Fix that fixes problem",
|
||||
"code": "fixed code"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be true
|
||||
|
||||
# Coverage: generate_fix includes previous attempts in prompt
|
||||
Scenario: Generate fix includes previous attempt descriptions in prompt
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a valid JSON fix response
|
||||
And I have a state with two previous fix attempts
|
||||
When I execute the generate_fix step
|
||||
Then the current_fix should have a description field
|
||||
And the attempt number should be 3
|
||||
|
||||
# Edge case: Empty content from LLM in generate_fix
|
||||
Scenario: Generate fix handles empty non-mock response
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns an empty non-mock response for fix generation
|
||||
And I have a state with error analysis completed
|
||||
When I execute the generate_fix step
|
||||
Then the current_fix should have a description field
|
||||
And the current_fix code should be empty string
|
||||
|
||||
# Edge case: Validation response with is_valid as string
|
||||
Scenario: Validate fix handles is_valid as string "true" in JSON
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a JSON validation response with is_valid as string "true"
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Fix",
|
||||
"code": "code"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be true
|
||||
|
||||
# Edge case: Validation response with is_valid missing
|
||||
Scenario: Validate fix handles missing is_valid field in JSON response
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a JSON validation response without is_valid field
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Fix",
|
||||
"code": "code"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be false
|
||||
|
||||
# Edge case: Case insensitive keyword detection in validation fallback
|
||||
Scenario: Validate fix keyword detection is case insensitive
|
||||
Given I have an AutoDebugAgent instance
|
||||
And the LLM returns a non-JSON validation response "VALID solution found"
|
||||
And I have a state with a current fix:
|
||||
"""
|
||||
{
|
||||
"description": "Fix",
|
||||
"code": "code"
|
||||
}
|
||||
"""
|
||||
When I execute the validate_fix step
|
||||
Then the fix_validated should be true
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
Feature: Auto Debug CLI Command Coverage
|
||||
As a developer
|
||||
I want reliable coverage for the auto_debug CLI entry points
|
||||
So that build automation paths remain stable
|
||||
|
||||
Background:
|
||||
Given I have a clean auto_debug test environment
|
||||
|
||||
# auto_debug_command() coverage
|
||||
Scenario: Auto debug command succeeds on first attempt
|
||||
Given I have an initialized project for auto_debug
|
||||
And the plan service auto_debug_build will succeed
|
||||
When I call the auto_debug_command programmatic interface
|
||||
Then the auto_debug_command should return success True
|
||||
And the attempts made should be 1
|
||||
|
||||
Scenario: Auto debug command fails after max attempts
|
||||
Given I have an initialized project for auto_debug
|
||||
And the plan service auto_debug_build will fail
|
||||
When I call the auto_debug_command with max_attempts 3
|
||||
Then the auto_debug_command should return success False
|
||||
And the attempts made should be 3
|
||||
|
||||
Scenario: Auto debug command with no project raises error
|
||||
Given I have no project initialized
|
||||
When I call the auto_debug_command programmatic interface expecting error
|
||||
Then a CleverAgentsError should be raised with message "No project found"
|
||||
|
||||
# _get_current_project() coverage
|
||||
Scenario: Get current project when project exists
|
||||
Given I have an initialized project for auto_debug
|
||||
When I call _get_current_project
|
||||
Then the project should be returned successfully
|
||||
|
||||
Scenario: Get current project when no project exists
|
||||
Given I have no project initialized
|
||||
When I call _get_current_project expecting abort
|
||||
Then typer.Abort should be raised
|
||||
|
||||
# run command happy path
|
||||
Scenario: Auto debug run command with successful build
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will succeed with 5 changes
|
||||
When I run the auto_debug run command
|
||||
Then the command should exit with code 0
|
||||
And the output should contain "Generated 5 change(s)"
|
||||
And the output should contain "AutoDebug Complete"
|
||||
|
||||
Scenario: Auto debug run command with build failure then success
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will fail then succeed after fix
|
||||
When I run the auto_debug run command with max_attempts 3
|
||||
Then the command should exit with code 0
|
||||
And the output should contain "Build failed"
|
||||
And the output should contain "Fix generated"
|
||||
And the output should contain "Build succeeded"
|
||||
|
||||
Scenario: Auto debug run command exceeds max attempts
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will always fail with error "Final failure"
|
||||
When I run the auto_debug run command with max_attempts 2
|
||||
Then the command should exit with code 1
|
||||
And the output should contain "Build failed after"
|
||||
And the output should contain "Final error"
|
||||
And the output should contain "Final failure"
|
||||
|
||||
Scenario: Auto debug run command fails with empty error message
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will always fail with empty error message
|
||||
When I run the auto_debug run command with max_attempts 2
|
||||
Then the command should exit with code 1
|
||||
And the output should contain "Build failed after"
|
||||
And the output should not contain "Final error"
|
||||
|
||||
Scenario: Auto debug run command handles PlanError with details
|
||||
Given I have an initialized project for auto_debug
|
||||
And the plan service will raise a PlanError with details
|
||||
When I run the auto_debug run command
|
||||
Then the command should be aborted
|
||||
And the output should contain "Plan Error"
|
||||
And the output should contain "phase:"
|
||||
|
||||
Scenario: Auto debug run command handles PlanError without details
|
||||
Given I have an initialized project for auto_debug
|
||||
And the plan service will raise a PlanError without details
|
||||
When I run the auto_debug run command
|
||||
Then the command should be aborted
|
||||
And the output should contain "Plan Error"
|
||||
And the output should not contain "phase:"
|
||||
|
||||
Scenario: Auto debug run command handles CleverAgentsError
|
||||
Given I have an initialized project for auto_debug
|
||||
And the plan service will raise a CleverAgentsError
|
||||
When I run the auto_debug run command
|
||||
Then the command should be aborted
|
||||
And the output should contain "Error:"
|
||||
|
||||
Scenario: Auto debug run command handles fix generation error
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will fail with fix generation error
|
||||
When I run the auto_debug run command with max_attempts 2
|
||||
Then the command should exit with code 0
|
||||
And the output should contain "Could not generate fix"
|
||||
|
||||
# Edge case: Long error message truncation
|
||||
Scenario: Auto debug run command truncates long error messages
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will always fail with error "This is a very long error message that exceeds eighty characters and should be truncated in the display output"
|
||||
When I run the auto_debug run command with max_attempts 1
|
||||
Then the command should exit with code 1
|
||||
And the output should contain "..."
|
||||
And the output should contain "Build failed after"
|
||||
|
||||
# Edge case: Minimum max_attempts boundary
|
||||
Scenario: Auto debug run command with minimum max_attempts of 1
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will always fail with error "Single attempt failure"
|
||||
When I run the auto_debug run command with max_attempts 1
|
||||
Then the command should exit with code 1
|
||||
And the output should contain "Attempt 1/1"
|
||||
And the output should contain "Build failed after 1 attempt"
|
||||
|
||||
# Edge case: Multiple sequential failures before success
|
||||
Scenario: Auto debug run command succeeds after multiple failures
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will fail 2 times then succeed with 3 changes
|
||||
When I run the auto_debug run command with max_attempts 5
|
||||
Then the command should exit with code 0
|
||||
And the output should contain "Build failed"
|
||||
And the output should contain "Build successful"
|
||||
And the output should contain "Generated 3 change(s)"
|
||||
|
||||
# Edge case: Short error message (no truncation)
|
||||
Scenario: Auto debug run command shows short error without truncation
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will always fail with error "Short error"
|
||||
When I run the auto_debug run command with max_attempts 1
|
||||
Then the command should exit with code 1
|
||||
And the output should contain "Short error"
|
||||
And the output should not contain "Short error..."
|
||||
|
||||
# Edge case: PlanError re-raised from within build loop
|
||||
Scenario: Auto debug run command re-raises PlanError from build
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will raise PlanError after one attempt
|
||||
When I run the auto_debug run command with max_attempts 3
|
||||
Then the command should be aborted
|
||||
And the output should contain "Plan Error"
|
||||
|
||||
# Edge case: CleverAgentsError re-raised from within build loop
|
||||
Scenario: Auto debug run command re-raises CleverAgentsError from build
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will raise CleverAgentsError after one attempt
|
||||
When I run the auto_debug run command with max_attempts 3
|
||||
Then the command should be aborted
|
||||
And the output should contain "Error:"
|
||||
|
||||
# Edge case: auto_debug_command with custom max_attempts
|
||||
Scenario: Auto debug command with custom max_attempts of 5
|
||||
Given I have an initialized project for auto_debug
|
||||
And the plan service auto_debug_build will fail
|
||||
When I call the auto_debug_command with max_attempts 5
|
||||
Then the auto_debug_command should return success False
|
||||
And the attempts made should be 5
|
||||
|
||||
# Edge case: auto_debug_command with max_attempts of 1
|
||||
Scenario: Auto debug command with minimum max_attempts of 1
|
||||
Given I have an initialized project for auto_debug
|
||||
And the plan service auto_debug_build will fail
|
||||
When I call the auto_debug_command with max_attempts 1
|
||||
Then the auto_debug_command should return success False
|
||||
And the attempts made should be 1
|
||||
|
||||
# Edge case: Successful build on exact last attempt
|
||||
Scenario: Auto debug run command succeeds on the last attempt
|
||||
Given I have an initialized project for auto_debug
|
||||
And the build will fail 2 times then succeed with 1 changes
|
||||
When I run the auto_debug run command with max_attempts 3
|
||||
Then the command should exit with code 0
|
||||
And the output should contain "Build succeeded after 3 attempt"
|
||||
|
||||
# Edge case: Multiple PlanError details
|
||||
Scenario: Auto debug run command handles PlanError with multiple details
|
||||
Given I have an initialized project for auto_debug
|
||||
And the plan service will raise a PlanError with multiple details
|
||||
When I run the auto_debug run command
|
||||
Then the command should be aborted
|
||||
And the output should contain "Plan Error"
|
||||
And the output should contain "phase:"
|
||||
And the output should contain "step:"
|
||||
@@ -0,0 +1,164 @@
|
||||
Feature: Context Service Analysis Integration
|
||||
As a developer using CleverAgents
|
||||
I want to analyze context files using LangGraph workflows
|
||||
So that I can understand the codebase better before making changes
|
||||
|
||||
Background:
|
||||
Given I have initialized a CleverAgents project
|
||||
And I have a context service with LangGraph integration
|
||||
|
||||
Scenario: Analyze context when no files are loaded
|
||||
Given the current plan has no context files
|
||||
When I analyze the context
|
||||
Then the analysis result should have empty documents
|
||||
And the analysis summary should indicate no files to analyze
|
||||
And there should be no error in the analysis
|
||||
|
||||
Scenario: Analyze context with a single Python file
|
||||
Given I have a Python file "main.py" with content:
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def main():
|
||||
print("Hello, World!")
|
||||
"""
|
||||
And I have added "main.py" to the LangGraph context
|
||||
When I analyze the context
|
||||
Then the analysis result should have 1 document
|
||||
And the dependencies for "main.py" should include "os"
|
||||
And the relevance score for "main.py" should be between 0.0 and 1.0
|
||||
And the summary should not be empty
|
||||
|
||||
Scenario: Analyze context with multiple files
|
||||
Given I have a Python file "utils.py" with content:
|
||||
"""
|
||||
def helper():
|
||||
return 42
|
||||
"""
|
||||
And I have a Python file "app.py" with content:
|
||||
"""
|
||||
from utils import helper
|
||||
print(helper())
|
||||
"""
|
||||
And I have added "utils.py" to the LangGraph context
|
||||
And I have added "app.py" to the LangGraph context
|
||||
When I analyze the context
|
||||
Then the analysis result should have 2 documents
|
||||
And the dependencies should include entries for both files
|
||||
And the relevance scores should have entries for both files
|
||||
|
||||
Scenario: Get context summary
|
||||
Given I have a Python file "sample.py" with content:
|
||||
"""
|
||||
# Sample module
|
||||
class Sample:
|
||||
pass
|
||||
"""
|
||||
And I have added "sample.py" to the LangGraph context
|
||||
When I get the context summary
|
||||
Then the summary should be a non-empty string
|
||||
And the summary should not indicate an error
|
||||
|
||||
Scenario: Get context dependencies
|
||||
Given I have a Python file "deps.py" with content:
|
||||
"""
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict
|
||||
"""
|
||||
And I have added "deps.py" to the LangGraph context
|
||||
When I get the context dependencies
|
||||
Then the dependencies dict should have an entry for "deps.py"
|
||||
And the dependencies should be a non-empty list
|
||||
|
||||
Scenario: Get relevant files with threshold
|
||||
Given I have a Python file "important.py" with content:
|
||||
"""
|
||||
# Core business logic
|
||||
def critical_function():
|
||||
return "important"
|
||||
"""
|
||||
And I have a Python file "trivial.py" with content:
|
||||
"""
|
||||
# Just comments
|
||||
pass
|
||||
"""
|
||||
And I have added "important.py" to the LangGraph context
|
||||
And I have added "trivial.py" to the LangGraph context
|
||||
When I get relevant files with threshold 0.0
|
||||
Then the result should include both files with scores
|
||||
And all LangGraph scores should be between 0.0 and 1.0
|
||||
|
||||
Scenario: Stream context analysis
|
||||
Given I have a Python file "streaming.py" with content:
|
||||
"""
|
||||
print("test streaming")
|
||||
"""
|
||||
And I have added "streaming.py" to the LangGraph context
|
||||
When I stream the context analysis
|
||||
Then I should receive multiple events
|
||||
And the events should include node execution results
|
||||
|
||||
Scenario: Stream context analysis when no files are loaded
|
||||
Given the current plan has no context files
|
||||
When I stream the context analysis
|
||||
Then the streaming output should report no files to analyze
|
||||
|
||||
Scenario: Analyze context asynchronously
|
||||
|
||||
Given I have a Python file "async_test.py" with content:
|
||||
"""
|
||||
async def async_func():
|
||||
return "async"
|
||||
"""
|
||||
And I have added "async_test.py" to the LangGraph context
|
||||
When I analyze the context asynchronously
|
||||
Then the async result should have documents
|
||||
And the async result should have a summary
|
||||
And there should be no error in the async result
|
||||
|
||||
Scenario: Analyze context asynchronously when no files are loaded
|
||||
Given the current plan has no context files
|
||||
When I analyze the context asynchronously
|
||||
Then the async analysis result should have empty documents
|
||||
And the async analysis summary should indicate no files to analyze
|
||||
|
||||
Scenario: Stream context analysis asynchronously with loaded files
|
||||
Given I have a Python file "async_stream.py" with content:
|
||||
"""
|
||||
print("async stream")
|
||||
"""
|
||||
And I have added "async_stream.py" to the LangGraph context
|
||||
When I stream the context analysis asynchronously
|
||||
Then the async streaming events should include node execution results
|
||||
|
||||
Scenario: Stream context analysis asynchronously with no files
|
||||
Given the current plan has no context files
|
||||
When I stream the context analysis asynchronously
|
||||
Then the async streaming output should report no files to analyze
|
||||
|
||||
Scenario: Handle non-existent files gracefully
|
||||
Given I have added a non-existent file path to the analysis
|
||||
|
||||
|
||||
When I analyze the context with the non-existent path
|
||||
Then the analysis should complete without raising an exception
|
||||
And the error field should contain file not found information
|
||||
|
||||
Scenario: Get context agent instance
|
||||
When I request a context analysis agent
|
||||
Then I should receive a ContextAnalysisAgent instance
|
||||
And the agent should have the standard workflow nodes
|
||||
|
||||
Scenario: Prepare LangSmith metadata when tracing is enabled
|
||||
Given LangSmith tracing is enabled for context analysis metadata
|
||||
And I have a Python file "langsmith.py" with content:
|
||||
"""
|
||||
print("langsmith tracing")
|
||||
"""
|
||||
And I have added "langsmith.py" to the LangGraph context
|
||||
When I prepare the LangGraph analysis config for run "LangSmithRun" in "stream" mode
|
||||
Then the LangSmith config should include the current project metadata
|
||||
And the LangSmith config should record 1 context file path
|
||||
+20
-1
@@ -5,6 +5,18 @@ import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
LANGSMITH_ENV_VARS = [
|
||||
"CLEVERAGENTS_LANGSMITH_ENABLED",
|
||||
"CLEVERAGENTS_LANGSMITH_PROJECT",
|
||||
"CLEVERAGENTS_LANGSMITH_ENDPOINT",
|
||||
"CLEVERAGENTS_LANGSMITH_API_KEY",
|
||||
"CLEVERAGENTS_LANGSMITH_TRACING_V2",
|
||||
"LANGCHAIN_TRACING_V2",
|
||||
"LANGCHAIN_PROJECT",
|
||||
"LANGCHAIN_ENDPOINT",
|
||||
"LANGCHAIN_API_KEY",
|
||||
]
|
||||
|
||||
|
||||
def before_all(context):
|
||||
"""Set up test environment before all tests."""
|
||||
@@ -37,7 +49,11 @@ def before_scenario(context, scenario):
|
||||
context._cleanup_handlers = []
|
||||
|
||||
# Clean up any lingering test environment variables from previous tests
|
||||
for env_var in ["CLEVERAGENTS_MOCK_SHOULD_FAIL", "CLEVERAGENTS_MOCK_INVALID_CODE"]:
|
||||
for env_var in [
|
||||
"CLEVERAGENTS_MOCK_SHOULD_FAIL",
|
||||
"CLEVERAGENTS_MOCK_INVALID_CODE",
|
||||
*LANGSMITH_ENV_VARS,
|
||||
]:
|
||||
if env_var in os.environ:
|
||||
del os.environ[env_var]
|
||||
|
||||
@@ -83,6 +99,9 @@ def after_scenario(context, scenario):
|
||||
os.environ.pop(key, None)
|
||||
context.env_vars_to_clean = []
|
||||
|
||||
for env_var in LANGSMITH_ENV_VARS:
|
||||
os.environ.pop(env_var, None)
|
||||
|
||||
# Reset Settings singleton if it was used
|
||||
try:
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
Feature: LangSmith configuration detection
|
||||
As a developer configuring observability
|
||||
I want to verify CleverAgents detects LangSmith settings
|
||||
So that tracing can be enabled or disabled safely
|
||||
|
||||
Background:
|
||||
Given LangSmith environment is clean
|
||||
|
||||
Scenario: LangSmith tracing disabled by default
|
||||
When I load CleverAgents settings for LangSmith
|
||||
Then LangSmith tracing should be disabled
|
||||
|
||||
Scenario: LangSmith tracing enabled via LangChain env vars
|
||||
Given I set environment variable "LANGCHAIN_TRACING_V2" to "true"
|
||||
And I set environment variable "LANGCHAIN_API_KEY" to "demo-key"
|
||||
When I load CleverAgents settings for LangSmith
|
||||
Then LangSmith tracing should be enabled
|
||||
|
||||
Scenario: Building LangSmith config metadata
|
||||
Given I set environment variable "CLEVERAGENTS_LANGSMITH_ENABLED" to "true"
|
||||
And I set environment variable "CLEVERAGENTS_LANGSMITH_PROJECT" to "cleveragents-core"
|
||||
And I set environment variable "CLEVERAGENTS_LANGSMITH_API_KEY" to "demo-key"
|
||||
When I load CleverAgents settings for LangSmith
|
||||
And I build a LangSmith config with run name "demo-run"
|
||||
Then the LangSmith config should include tag "context-analysis"
|
||||
And the LangSmith config should include metadata key "langsmith_project"
|
||||
@@ -50,4 +50,19 @@ Feature: Plan Service
|
||||
Given I have a plan service
|
||||
And I have created a plan with "Initial instructions"
|
||||
When I add "Additional instructions" to the plan
|
||||
Then the plan should contain both instructions
|
||||
Then the plan should contain both instructions
|
||||
|
||||
Scenario: Auto-debug build uses empty context when plan ID disappears
|
||||
Given I have a temporary test directory for plan service
|
||||
And I configured an auto-debug plan with a disappearing plan ID
|
||||
When I run auto-debug build with missing plan context
|
||||
Then the auto-debug build should fall back to an empty context
|
||||
|
||||
Scenario: Apply changes moves files to absolute destinations
|
||||
Given I have a temporary test directory for plan service
|
||||
And I have a Unit of Work instance for plan testing
|
||||
And I have a PlanService instance
|
||||
And I have a saved project with current plan
|
||||
And the plan has a pending MOVE change to an absolute path
|
||||
When I apply the plan changes
|
||||
Then the absolute destination should exist and the source should be removed
|
||||
|
||||
@@ -51,3 +51,31 @@ Feature: Plan Service Uncovered Lines Coverage
|
||||
Then the current plan prompt should be "New instructions"
|
||||
And the current plan status should be PENDING
|
||||
And the current plan updated_at should be refreshed
|
||||
|
||||
Scenario: LangSmith config includes project and plan metadata when enabled
|
||||
Given LangSmith integration is enabled for plan service
|
||||
When I prepare a LangSmith config for project "Docs" and plan "Improve docs"
|
||||
Then the LangSmith builder should receive metadata for project "Docs" and plan "Improve docs"
|
||||
And the prepared LangSmith config should include a generated thread id
|
||||
|
||||
Scenario: Build plan surfaces provider errors
|
||||
Given I have a plan service with a failing AI provider
|
||||
And I have a saved project with current plan
|
||||
When I attempt to build the plan and the provider returns an error
|
||||
Then a PlanError should be raised with message "mock provider failure"
|
||||
|
||||
Scenario: Auto debug build requires a current plan
|
||||
Given I have a saved project with no current plan
|
||||
When I run the auto debug build for the project
|
||||
Then a PlanError should be raised with message "No current plan to build"
|
||||
|
||||
Scenario: Auto debug build reports failure after retries
|
||||
Given I have a saved project with current plan
|
||||
And auto debug retries should run with a failing build pipeline
|
||||
When I run the auto debug build with max attempts 2
|
||||
Then the auto debug result should indicate failure with error message "Simulated auto debug failure"
|
||||
|
||||
Scenario: Streaming generation requires an AI provider
|
||||
Given I have a saved project
|
||||
When I try to stream plan generation without an AI provider
|
||||
Then a PlanError should be raised with message "No AI provider configured"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
Feature: Settings runtime helpers
|
||||
Verify environment alias, storage paths, database URLs, production flags,
|
||||
provider configuration lookups, and LangSmith config guards behave predictably.
|
||||
|
||||
Scenario: Environment alias setter syncs env field
|
||||
Given no environment variables are set
|
||||
When I load the settings with defaults
|
||||
And I set the environment alias to "staging"
|
||||
Then the environment alias should be "staging"
|
||||
And the environment should be "staging"
|
||||
|
||||
Scenario: Storage base path trims whitespace
|
||||
Given the storage base path is "/tmp/storage"
|
||||
When I compute the storage path for " logs "
|
||||
Then the storage path should be "/tmp/storage/logs"
|
||||
|
||||
Scenario: Storage base path returns default when type missing
|
||||
Given the storage base path is "/tmp/storage"
|
||||
When I compute the default storage path
|
||||
Then the storage path should be "/tmp/storage"
|
||||
|
||||
Scenario: SQLite URLs derive implicit test database
|
||||
Given no environment variables are set
|
||||
When I derive the test database URL from "sqlite:///primary.db"
|
||||
Then the derived test database URL should be "sqlite:///primary_test.db"
|
||||
|
||||
Scenario: Non-SQLite URLs fall back to original string
|
||||
When I derive the test database URL from "postgresql://localhost/app"
|
||||
Then the derived test database URL should be "postgresql://localhost/app"
|
||||
|
||||
Scenario: Production mode helpers honour flags
|
||||
When I evaluate production mode with env "production", debug "false", and reload "false"
|
||||
Then is_production should be True
|
||||
And is_production_mode should be True
|
||||
|
||||
Scenario: Unknown provider alias short-circuits lookups
|
||||
Given no API keys are set
|
||||
When I check if "unknown" provider is configured
|
||||
Then has_provider_configured should be False for "unknown"
|
||||
|
||||
Scenario: Explicit provider values skip env overrides
|
||||
Given the environment variable "OPENAI_API_KEY" is set to "env-key"
|
||||
When I instantiate settings with explicit openai API key "constructor-key"
|
||||
Then the openai API key should be "constructor-key"
|
||||
|
||||
Scenario: Provider env vars satisfy configuration checks
|
||||
Given no API keys are set
|
||||
And the openai API key is set to "env-key"
|
||||
When I check if "openai" provider is configured
|
||||
Then has_provider_configured should be True for "openai"
|
||||
|
||||
Scenario: Provider fallback reads env vars when attribute unset
|
||||
Given no API keys are set
|
||||
And the openai API key is set to "env-key"
|
||||
When I clear the explicit "openai" key and recheck configuration
|
||||
Then has_provider_configured should be True for "openai"
|
||||
|
||||
Scenario: LangSmith config builder returns None when disabled
|
||||
When I build the LangSmith config
|
||||
Then the LangSmith config should be absent
|
||||
@@ -198,6 +198,12 @@ def step_verify_env_prefix(context, prefix):
|
||||
"GOOGLE_",
|
||||
"OPENROUTER_",
|
||||
"HF_",
|
||||
"HUGGINGFACEHUB_",
|
||||
"HUGGING_FACE_HUB_",
|
||||
"COHERE_",
|
||||
"PERPLEXITY_",
|
||||
"GROQ_",
|
||||
"TOGETHER_",
|
||||
]
|
||||
|
||||
# Common constants and system variables to exclude
|
||||
@@ -244,6 +250,14 @@ def step_verify_provider_vars(context):
|
||||
"GEMINI_",
|
||||
"AZURE_",
|
||||
"GOOGLE_",
|
||||
"OPENROUTER_",
|
||||
"HF_",
|
||||
"HUGGINGFACEHUB_",
|
||||
"HUGGING_FACE_HUB_",
|
||||
"COHERE_",
|
||||
"PERPLEXITY_",
|
||||
"GROQ_",
|
||||
"TOGETHER_",
|
||||
]
|
||||
provider_vars = [
|
||||
var
|
||||
|
||||
@@ -165,6 +165,41 @@ def step_error_analysis_mentions(context, text):
|
||||
assert any(text.lower() in msg.get("content", "").lower() for msg in analysis_msgs)
|
||||
|
||||
|
||||
@given('the LLM returns a non-mock analysis response "{response}"')
|
||||
def step_llm_returns_non_mock_analysis_response(context, response):
|
||||
"""Configure the mock LLM to return a specific analysis response."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = response
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@given("the LLM raises an exception during analysis")
|
||||
def step_llm_raises_exception_analysis(context):
|
||||
"""Configure the mock LLM to raise during analysis."""
|
||||
context.mock_llm.invoke.side_effect = Exception("LLM analysis failed")
|
||||
|
||||
|
||||
@then('the error_analysis content should be "{expected_content}"')
|
||||
def step_error_analysis_content_equals(context, expected_content):
|
||||
"""Assert the last error_analysis message matches expected content."""
|
||||
messages = context.state.get("messages", [])
|
||||
analysis_msg = next(
|
||||
(msg for msg in messages if msg.get("type") == "error_analysis"), None
|
||||
)
|
||||
assert analysis_msg is not None, "No error_analysis message found"
|
||||
assert analysis_msg.get("content") == expected_content
|
||||
|
||||
|
||||
@then('a warning should be logged containing "{text}"')
|
||||
def step_warning_logged_contains(context, text):
|
||||
"""Verify a warning with the provided text was logged."""
|
||||
assert hasattr(context, "log_capture"), "Log capture not initialized"
|
||||
assert any(text in log for log in context.log_capture), (
|
||||
f"Expected warning containing '{text}'"
|
||||
)
|
||||
|
||||
|
||||
@given("I have a state with error analysis completed")
|
||||
def step_have_state_with_error_analysis(context):
|
||||
"""Create a state with completed error analysis."""
|
||||
@@ -209,6 +244,89 @@ def step_current_fix_has_code(context):
|
||||
assert "code" in current_fix
|
||||
|
||||
|
||||
@given("the LLM returns a valid JSON fix response")
|
||||
def step_llm_returns_valid_json_fix(context):
|
||||
"""Configure the LLM to return valid JSON during fix generation."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = json.dumps(
|
||||
{
|
||||
"description": "Add variable definition",
|
||||
"code": "x = 0; return x + 1",
|
||||
"files_to_modify": ["test.py"],
|
||||
}
|
||||
)
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@given('the LLM returns a non-JSON fix response "{response}"')
|
||||
def step_llm_returns_non_json_fix(context, response):
|
||||
"""Configure the LLM to return plain text during fix generation."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = response
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@given("the LLM raises an exception during fix generation")
|
||||
def step_llm_raises_exception_fix_generation(context):
|
||||
"""Configure the LLM to raise during fix generation."""
|
||||
context.mock_llm.invoke.side_effect = Exception("LLM fix generation failed")
|
||||
|
||||
|
||||
@given("the LLM returns an empty non-mock response for fix generation")
|
||||
def step_llm_returns_empty_fix_response(context):
|
||||
"""Configure the LLM to return an empty string for fix generation."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = ""
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@then('the current_fix description should be "{description}"')
|
||||
def step_current_fix_description_equals(context, description):
|
||||
"""Verify the current_fix description."""
|
||||
current_fix = context.state.get("current_fix", {})
|
||||
assert current_fix.get("description") == description
|
||||
|
||||
|
||||
@then('the current_fix code should be "{code}"')
|
||||
def step_current_fix_code_equals(context, code):
|
||||
"""Verify the current_fix code."""
|
||||
current_fix = context.state.get("current_fix", {})
|
||||
assert current_fix.get("code") == code
|
||||
|
||||
|
||||
@then('the current_fix files_to_modify should contain "{filename}"')
|
||||
def step_current_fix_files_contains(context, filename):
|
||||
"""Ensure files_to_modify includes the provided filename."""
|
||||
current_fix = context.state.get("current_fix", {})
|
||||
files = current_fix.get("files_to_modify", [])
|
||||
assert filename in files
|
||||
|
||||
|
||||
@then('the current_fix description should contain "{text}"')
|
||||
def step_current_fix_description_contains(context, text):
|
||||
"""Verify the description contains specific text."""
|
||||
current_fix = context.state.get("current_fix", {})
|
||||
description = current_fix.get("description", "")
|
||||
assert text in description
|
||||
|
||||
|
||||
@then("the current_fix files_to_modify should be empty")
|
||||
def step_current_fix_files_empty(context):
|
||||
"""Ensure no files_to_modify are listed."""
|
||||
current_fix = context.state.get("current_fix", {})
|
||||
assert not current_fix.get("files_to_modify")
|
||||
|
||||
|
||||
@then("the current_fix code should be empty string")
|
||||
def step_current_fix_code_empty(context):
|
||||
"""Ensure the fix code is an empty string."""
|
||||
current_fix = context.state.get("current_fix", {})
|
||||
assert current_fix.get("code") == ""
|
||||
|
||||
|
||||
@given("I have a state with a current fix:")
|
||||
def step_have_state_with_current_fix(context):
|
||||
"""Create a state with a current fix."""
|
||||
@@ -222,6 +340,78 @@ def step_have_state_with_current_fix(context):
|
||||
}
|
||||
|
||||
|
||||
@given("I have a state with current fix and empty attempted_fixes:")
|
||||
def step_state_with_current_fix_and_empty_attempts(context):
|
||||
"""Create a state with a current fix and no attempts."""
|
||||
current_fix = json.loads(context.text)
|
||||
context.state = {
|
||||
"messages": [],
|
||||
"current_fix": current_fix,
|
||||
"attempted_fixes": [],
|
||||
"error_message": "Test error",
|
||||
"code_context": "test code",
|
||||
}
|
||||
|
||||
|
||||
@then("the attempted_fixes should have {count:d} entry")
|
||||
@then("the attempted_fixes should have {count:d} entries")
|
||||
def step_attempted_fixes_count(context, count):
|
||||
"""Verify the number of attempted fixes."""
|
||||
attempts = context.state.get("attempted_fixes", [])
|
||||
assert len(attempts) == count
|
||||
|
||||
|
||||
@then("the attempted_fixes should contain the current fix")
|
||||
def step_attempted_fixes_contains_current(context):
|
||||
"""Ensure the latest current fix is tracked in attempted_fixes."""
|
||||
attempts = context.state.get("attempted_fixes", [])
|
||||
current_fix = context.state.get("current_fix")
|
||||
assert current_fix in attempts
|
||||
|
||||
|
||||
@given("I have a state with current fix and one existing attempted fix")
|
||||
def step_state_with_one_attempt(context):
|
||||
"""Create state with one attempted fix already recorded."""
|
||||
context.state = {
|
||||
"messages": [],
|
||||
"current_fix": {"description": "Another failed fix", "code": "more bad code"},
|
||||
"attempted_fixes": [{"description": "Failed fix", "code": "bad code"}],
|
||||
"error_message": "Test error",
|
||||
"code_context": "test code",
|
||||
}
|
||||
|
||||
|
||||
@given("I have a state with two previous fix attempts")
|
||||
def step_state_with_two_previous_attempts(context):
|
||||
"""Create a state containing two previous attempts for prompt context."""
|
||||
context.state = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Error analysis complete",
|
||||
"type": "error_analysis",
|
||||
}
|
||||
],
|
||||
"error_message": "Test error",
|
||||
"code_context": "test code",
|
||||
"attempted_fixes": [
|
||||
{"description": "Failed fix 1", "code": "bad code 1"},
|
||||
{"description": "Failed fix 2", "code": "bad code 2"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@then("the attempt number should be {count:d}")
|
||||
def step_attempt_number_is(context, count):
|
||||
"""Verify the prompt references the expected attempt number."""
|
||||
call_args = getattr(context.mock_llm.invoke, "call_args", None)
|
||||
assert call_args, "LLM was not invoked"
|
||||
messages = call_args[0][0]
|
||||
human_message = messages[-1]
|
||||
content = getattr(human_message, "content", "")
|
||||
assert f"Generate fix attempt #{count}" in content
|
||||
|
||||
|
||||
@when("I execute the validate_fix step")
|
||||
def step_execute_validate_fix(context):
|
||||
"""Execute the validate_fix step."""
|
||||
@@ -242,6 +432,57 @@ def step_fix_validated_is_value(context, value):
|
||||
assert context.state.get("fix_validated") == expected
|
||||
|
||||
|
||||
@given("the LLM returns a valid JSON validation response with is_valid true")
|
||||
def step_llm_returns_valid_json_validation_true(context):
|
||||
"""Configure the LLM to return JSON with is_valid true."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = json.dumps({"is_valid": True})
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@given("the LLM returns a valid JSON validation response with is_valid false")
|
||||
def step_llm_returns_valid_json_validation_false(context):
|
||||
"""Configure the LLM to return JSON with is_valid false."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = json.dumps({"is_valid": False})
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@given('the LLM returns a non-JSON validation response "{response}"')
|
||||
def step_llm_returns_non_json_validation(context, response):
|
||||
"""Configure the LLM to return plain text during validation."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = response
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@given("the LLM raises an exception during validation")
|
||||
def step_llm_raises_exception_validation(context):
|
||||
"""Configure the LLM to raise during validation."""
|
||||
context.mock_llm.invoke.side_effect = Exception("LLM validation failed")
|
||||
|
||||
|
||||
@given('the LLM returns a JSON validation response with is_valid as string "{value}"')
|
||||
def step_llm_returns_string_is_valid(context, value):
|
||||
"""Configure JSON validation response with string is_valid."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = json.dumps({"is_valid": value})
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@given("the LLM returns a JSON validation response without is_valid field")
|
||||
def step_llm_returns_json_without_is_valid(context):
|
||||
"""Configure JSON lacking is_valid field."""
|
||||
mock_response = Mock()
|
||||
mock_response.content = json.dumps({"reasoning": "Missing flag"})
|
||||
context.mock_llm.invoke.side_effect = None
|
||||
context.mock_llm.invoke.return_value = mock_response
|
||||
|
||||
|
||||
@given("I have an AutoDebugAgent instance with max_fix_attempts of {value:d}")
|
||||
def step_have_auto_debug_agent_with_max_attempts(context, value):
|
||||
"""Create AutoDebugAgent with specific max_fix_attempts."""
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from typing import Any, Iterable
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import typer
|
||||
from behave import given, then, when
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.auto_debug import (
|
||||
app as auto_debug_app,
|
||||
auto_debug_command,
|
||||
_get_current_project,
|
||||
)
|
||||
from cleveragents.application.services.plan_service import PlanService
|
||||
from cleveragents.core.exceptions import CleverAgentsError, PlanError
|
||||
from cleveragents.domain.models.core.project import Project
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class _FakeLive:
|
||||
"""Minimal Live replacement that writes updates to the console."""
|
||||
|
||||
def __init__(self, *_args, console=None, **_kwargs):
|
||||
self._console = console
|
||||
|
||||
def __enter__(self) -> "_FakeLive":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool: # noqa: D401
|
||||
return False
|
||||
|
||||
def update(self, renderable) -> None:
|
||||
if self._console is not None:
|
||||
self._console.print(renderable)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patched_container(context, project: Project | None):
|
||||
with patch(
|
||||
"cleveragents.application.container.get_container"
|
||||
) as mock_get_container:
|
||||
container = MagicMock()
|
||||
mock_get_container.return_value = container
|
||||
container.plan_service.return_value = context.plan_service_mock
|
||||
container.project_service.return_value.get_current_project.return_value = (
|
||||
project
|
||||
)
|
||||
yield container
|
||||
|
||||
|
||||
def _capture_output(context: Any) -> str:
|
||||
result = getattr(context, "result", None)
|
||||
if isinstance(result, dict):
|
||||
return result.get("output", "")
|
||||
if result is not None:
|
||||
stdout = getattr(result, "stdout", "") or ""
|
||||
stderr = getattr(result, "stderr", "") or ""
|
||||
output_attr = getattr(result, "output", "") or ""
|
||||
combined = stdout + stderr
|
||||
if combined:
|
||||
return combined
|
||||
if output_attr:
|
||||
return output_attr
|
||||
if hasattr(context, "output"):
|
||||
return context.output
|
||||
if hasattr(context, "command_output"):
|
||||
return context.command_output
|
||||
raise AssertionError("No CLI output captured in context")
|
||||
|
||||
|
||||
def _capture_exit_code(context: Any) -> int:
|
||||
result = getattr(context, "result", None)
|
||||
if isinstance(result, dict):
|
||||
return result.get("exit_code", 0)
|
||||
if hasattr(result, "exit_code"):
|
||||
return result.exit_code
|
||||
if hasattr(context, "exit_code"):
|
||||
return context.exit_code
|
||||
raise AssertionError("No exit code recorded in context")
|
||||
|
||||
|
||||
def _get_recorded_exception(context: Any) -> Exception | None:
|
||||
return getattr(context, "exception", None) or getattr(
|
||||
context, "call_exception", None
|
||||
)
|
||||
|
||||
|
||||
def _run_auto_debug_cli(context, extra_args: Iterable[str] | None = None) -> None:
|
||||
project = context.project
|
||||
assert project is not None, "Project must be initialized before running the CLI"
|
||||
args: list[str] = []
|
||||
if extra_args:
|
||||
args.extend(extra_args)
|
||||
|
||||
with _patched_container(context, project):
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch("cleveragents.cli.commands.auto_debug.Live", _FakeLive)
|
||||
)
|
||||
if getattr(context, "fail_fix_generation", False):
|
||||
stack.enter_context(_patch_time_sleep_failure())
|
||||
context.result = runner.invoke(auto_debug_app, args)
|
||||
context.command_output = _capture_output(context)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_time_sleep_failure():
|
||||
calls = {"count": 0}
|
||||
|
||||
def _sleep_override(seconds): # noqa: ARG001
|
||||
if calls["count"] == 0:
|
||||
calls["count"] += 1
|
||||
raise RuntimeError("Could not generate fix")
|
||||
|
||||
with patch("time.sleep", side_effect=_sleep_override):
|
||||
yield
|
||||
|
||||
|
||||
@given("I have a clean auto_debug test environment")
|
||||
def step_clean_environment(context):
|
||||
context.project = None
|
||||
context.result = None
|
||||
context.exception = None
|
||||
context.command_output = ""
|
||||
context.fail_fix_generation = False
|
||||
context.plan_service_mock = MagicMock(spec=PlanService)
|
||||
context.plan_service_mock.auto_debug_build.return_value = (True, [], None)
|
||||
context.plan_service_mock.build_plan.return_value = []
|
||||
context.plan_service_mock.build_plan.side_effect = None
|
||||
|
||||
|
||||
@given("I have an initialized project for auto_debug")
|
||||
def step_initialized_project(context):
|
||||
context.project = Project(
|
||||
id=1,
|
||||
name="Test Project",
|
||||
description="A test project",
|
||||
path="/tmp",
|
||||
)
|
||||
|
||||
|
||||
@given("I have no project initialized")
|
||||
def step_no_project(context):
|
||||
context.project = None
|
||||
|
||||
|
||||
@given("the plan service auto_debug_build will succeed")
|
||||
def step_autodebug_success(context):
|
||||
context.plan_service_mock.auto_debug_build.return_value = (True, [], None)
|
||||
|
||||
|
||||
@given("the plan service auto_debug_build will fail")
|
||||
def step_autodebug_failure(context):
|
||||
context.plan_service_mock.auto_debug_build.return_value = (
|
||||
False,
|
||||
[],
|
||||
"Simulated auto-debug failure",
|
||||
)
|
||||
|
||||
|
||||
@when("I call the auto_debug_command programmatic interface")
|
||||
def step_call_autodebug_command(context):
|
||||
with _patched_container(context, context.project):
|
||||
context.result = auto_debug_command()
|
||||
|
||||
|
||||
@when("I call the auto_debug_command with max_attempts {max_attempts:d}")
|
||||
def step_call_autodebug_command_with_limit(context, max_attempts):
|
||||
with _patched_container(context, context.project):
|
||||
context.result = auto_debug_command(max_attempts=max_attempts)
|
||||
|
||||
|
||||
@when("I call the auto_debug_command programmatic interface expecting error")
|
||||
def step_call_autodebug_command_error(context):
|
||||
try:
|
||||
with _patched_container(context, context.project):
|
||||
auto_debug_command()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
context.exception = exc
|
||||
|
||||
|
||||
@then("the auto_debug_command should return success True")
|
||||
def step_assert_autodebug_success(context):
|
||||
assert context.result[0] is True
|
||||
|
||||
|
||||
@then("the auto_debug_command should return success False")
|
||||
def step_assert_autodebug_failure(context):
|
||||
assert context.result[0] is False
|
||||
|
||||
|
||||
@then("the attempts made should be {count:d}")
|
||||
def step_assert_attempts(context, count):
|
||||
assert context.result[1] == count
|
||||
|
||||
|
||||
@then('a CleverAgentsError should be raised with message "{message}"')
|
||||
def step_assert_cleveragents_error(context, message):
|
||||
exception = _get_recorded_exception(context)
|
||||
assert exception is not None, (
|
||||
"Expected CleverAgentsError but no exception was recorded"
|
||||
)
|
||||
assert isinstance(exception, CleverAgentsError)
|
||||
assert message in str(exception)
|
||||
|
||||
|
||||
@when("I call _get_current_project")
|
||||
def step_call_get_current_project(context):
|
||||
with _patched_container(context, context.project):
|
||||
context.result = _get_current_project()
|
||||
|
||||
|
||||
@when("I call _get_current_project expecting abort")
|
||||
def step_call_get_current_project_abort(context):
|
||||
try:
|
||||
with _patched_container(context, None):
|
||||
_get_current_project()
|
||||
except typer.Abort as exc:
|
||||
context.exception = exc
|
||||
|
||||
|
||||
@then("the project should be returned successfully")
|
||||
def step_assert_project_returned(context):
|
||||
assert context.result == context.project
|
||||
|
||||
|
||||
@then("typer.Abort should be raised")
|
||||
def step_assert_abort(context):
|
||||
assert isinstance(_get_recorded_exception(context), typer.Abort)
|
||||
|
||||
|
||||
@given("the build will succeed with {changes:d} changes")
|
||||
def step_build_success(context, changes):
|
||||
context.plan_service_mock.build_plan.side_effect = None
|
||||
context.plan_service_mock.build_plan.return_value = [
|
||||
object() for _ in range(changes)
|
||||
]
|
||||
|
||||
|
||||
@given("the build will fail then succeed after fix")
|
||||
def step_build_fail_then_succeed(context):
|
||||
context.plan_service_mock.build_plan.side_effect = [
|
||||
RuntimeError("Build failed"),
|
||||
[object()],
|
||||
]
|
||||
|
||||
|
||||
@given('the build will always fail with error "{error_message}"')
|
||||
def step_build_always_fail(context, error_message):
|
||||
context.plan_service_mock.build_plan.side_effect = RuntimeError(error_message)
|
||||
|
||||
|
||||
@given("the build will always fail with empty error message")
|
||||
def step_build_fail_empty_error(context):
|
||||
context.plan_service_mock.build_plan.side_effect = RuntimeError("")
|
||||
|
||||
|
||||
@given("the plan service will raise a PlanError with details")
|
||||
def step_planerror_details(context):
|
||||
context.plan_service_mock.build_plan.side_effect = PlanError(
|
||||
message="Plan Error",
|
||||
details={"phase": "build"},
|
||||
)
|
||||
|
||||
|
||||
@given("the plan service will raise a PlanError without details")
|
||||
def step_planerror_no_details(context):
|
||||
context.plan_service_mock.build_plan.side_effect = PlanError(
|
||||
message="Plan Error",
|
||||
details=None,
|
||||
)
|
||||
|
||||
|
||||
@given("the plan service will raise a CleverAgentsError")
|
||||
def step_plan_cleveragents_error(context):
|
||||
context.plan_service_mock.build_plan.side_effect = CleverAgentsError(
|
||||
"Generic Error"
|
||||
)
|
||||
|
||||
|
||||
@given("the build will fail with fix generation error")
|
||||
def step_fix_generation_error(context):
|
||||
context.fail_fix_generation = True
|
||||
context.plan_service_mock.build_plan.side_effect = [
|
||||
RuntimeError("Build failed"),
|
||||
[object()],
|
||||
]
|
||||
|
||||
|
||||
@when("I run the auto_debug run command")
|
||||
def step_run_cli(context):
|
||||
_run_auto_debug_cli(context)
|
||||
|
||||
|
||||
@when("I run the auto_debug run command with max_attempts {max_attempts:d}")
|
||||
def step_run_cli_with_limit(context, max_attempts):
|
||||
_run_auto_debug_cli(context, ["--max-attempts", str(max_attempts)])
|
||||
|
||||
|
||||
@then("the command should exit with code {code:d}")
|
||||
def step_assert_exit_code(context, code):
|
||||
actual = _capture_exit_code(context)
|
||||
if actual != code:
|
||||
output = _capture_output(context)
|
||||
assert actual == code, (
|
||||
f"Expected exit code {code}, got {actual}. CLI output:\n{output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the command should be aborted")
|
||||
def step_assert_command_aborted(context):
|
||||
step_assert_exit_code(context, 1)
|
||||
|
||||
|
||||
@then('the output should contain "{text}"')
|
||||
def step_assert_output_contains(context, text):
|
||||
output = _capture_output(context)
|
||||
assert text in output, f"Expected to find '{text}' in CLI output: {output}"
|
||||
|
||||
|
||||
@given(
|
||||
"the build will fail {num_failures:d} times then succeed with {changes:d} changes"
|
||||
)
|
||||
def step_build_fail_then_succeed_with_changes(context, num_failures, changes):
|
||||
"""Configure build to fail num_failures times then succeed."""
|
||||
failures = [
|
||||
RuntimeError(f"Build failed attempt {i + 1}") for i in range(num_failures)
|
||||
]
|
||||
success_result = [object() for _ in range(changes)]
|
||||
context.plan_service_mock.build_plan.side_effect = failures + [success_result]
|
||||
|
||||
|
||||
@given("the build will raise PlanError after one attempt")
|
||||
def step_build_raises_planerror_after_attempt(context):
|
||||
"""Configure build to raise PlanError after one failed attempt."""
|
||||
context.plan_service_mock.build_plan.side_effect = [
|
||||
RuntimeError("Initial failure"),
|
||||
PlanError(message="Plan Error during retry", details={"phase": "build"}),
|
||||
]
|
||||
|
||||
|
||||
@given("the build will raise CleverAgentsError after one attempt")
|
||||
def step_build_raises_cleveragentserror_after_attempt(context):
|
||||
"""Configure build to raise CleverAgentsError after one failed attempt."""
|
||||
context.plan_service_mock.build_plan.side_effect = [
|
||||
RuntimeError("Initial failure"),
|
||||
CleverAgentsError("Critical error during retry"),
|
||||
]
|
||||
|
||||
|
||||
@given("the plan service will raise a PlanError with multiple details")
|
||||
def step_planerror_multiple_details(context):
|
||||
context.plan_service_mock.build_plan.side_effect = PlanError(
|
||||
message="Plan Error",
|
||||
details={"phase": "build", "step": "validation", "file": "test.py"},
|
||||
)
|
||||
@@ -281,7 +281,10 @@ def step_run_command(context, command):
|
||||
env=os.environ.copy(), # Pass current environment variables
|
||||
)
|
||||
|
||||
context.command_output = result.stdout
|
||||
output = result.stdout or ""
|
||||
if result.stderr:
|
||||
output += result.stderr
|
||||
context.command_output = output
|
||||
context.command_error = result.stderr
|
||||
context.command_exit_code = result.returncode
|
||||
context.exit_code = result.returncode # For compatibility with other steps
|
||||
@@ -433,18 +436,6 @@ def step_verify_current_plan(context, name):
|
||||
assert current.name == name
|
||||
|
||||
|
||||
@then('the output should contain "{text}"')
|
||||
def step_output_contains(context, text):
|
||||
"""Verify output contains text."""
|
||||
# Check both stdout and stderr
|
||||
combined_output = context.command_output + (
|
||||
context.command_error if hasattr(context, "command_error") else ""
|
||||
)
|
||||
assert text in combined_output, (
|
||||
f"Expected '{text}' not found in output: {combined_output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the plan should have the additional instruction")
|
||||
def step_plan_has_additional(context):
|
||||
"""Verify plan has additional instruction."""
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import MethodType
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.agents.context_analysis import ContextAnalysisAgent
|
||||
from cleveragents.application.services.context_service import ContextService
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.domain.models.core import Plan, Project
|
||||
|
||||
|
||||
def _ensure_temp_project(context) -> None:
|
||||
if hasattr(context, "project") and hasattr(context, "temp_dir_path"):
|
||||
return
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="context-analysis-"))
|
||||
context.temp_dir_path = temp_dir
|
||||
if hasattr(context, "add_cleanup"):
|
||||
context.add_cleanup(shutil.rmtree, temp_dir, True)
|
||||
context.project = Project(id=1, name="context-analysis-project", path=temp_dir)
|
||||
|
||||
|
||||
def _ensure_context_service(context) -> None:
|
||||
if hasattr(context, "context_service"):
|
||||
return
|
||||
_ensure_temp_project(context)
|
||||
settings = Settings()
|
||||
unit_of_work = MagicMock()
|
||||
|
||||
transaction_manager = unit_of_work.transaction.return_value
|
||||
transaction_context = MagicMock()
|
||||
transaction_context.plans = MagicMock()
|
||||
plan = Plan(
|
||||
id=42,
|
||||
project_id=context.project.id or 1,
|
||||
name="analysis-plan",
|
||||
prompt="Analyze context for coverage",
|
||||
current=True,
|
||||
)
|
||||
transaction_context.plans.get_current_for_project.return_value = plan
|
||||
transaction_manager.__enter__.return_value = transaction_context
|
||||
transaction_manager.__exit__.return_value = False
|
||||
|
||||
service = ContextService(settings=settings, unit_of_work=unit_of_work)
|
||||
context.plan_metadata = plan
|
||||
context.context_files: list[Path] = []
|
||||
context.created_files: dict[str, Path] = {}
|
||||
|
||||
def list_files_stub(
|
||||
self: ContextService, project: Project | None = None
|
||||
) -> list[str]:
|
||||
return [str(path) for path in context.context_files]
|
||||
|
||||
service.list_files = MethodType(list_files_stub, service)
|
||||
context.context_service = service
|
||||
|
||||
|
||||
def _create_file(context, filename: str, content: str) -> Path:
|
||||
_ensure_temp_project(context)
|
||||
file_path = context.temp_dir_path / filename
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content.strip("\n"), encoding="utf-8")
|
||||
context.created_files[filename] = file_path
|
||||
return file_path
|
||||
|
||||
|
||||
def _add_file_to_context(context, filename: str) -> Path:
|
||||
if filename not in context.created_files:
|
||||
raise AssertionError(f"File '{filename}' has not been created yet")
|
||||
file_path = context.created_files[filename]
|
||||
if file_path not in context.context_files:
|
||||
context.context_files.append(file_path)
|
||||
return file_path
|
||||
|
||||
|
||||
@given("I have initialized a CleverAgents project")
|
||||
def step_init_project(context):
|
||||
_ensure_temp_project(context)
|
||||
|
||||
|
||||
@given("I have a context service with LangGraph integration")
|
||||
def step_context_service(context):
|
||||
_ensure_context_service(context)
|
||||
|
||||
|
||||
@given("LangSmith tracing is enabled for context analysis metadata")
|
||||
def step_enable_langsmith(context):
|
||||
_ensure_context_service(context)
|
||||
context.context_service.settings.langsmith_enabled = True
|
||||
context.context_service.settings.langsmith_project = "behave-context-coverage"
|
||||
|
||||
|
||||
@given("the current plan has no context files")
|
||||
def step_no_context_files(context):
|
||||
_ensure_context_service(context)
|
||||
context.context_files.clear()
|
||||
|
||||
|
||||
@given('I have a Python file "{filename}" with content:')
|
||||
def step_create_file(context, filename):
|
||||
_ensure_context_service(context)
|
||||
_create_file(context, filename, context.text)
|
||||
|
||||
|
||||
@given('I have added "{filename}" to the LangGraph context')
|
||||
def step_add_file(context, filename):
|
||||
_ensure_context_service(context)
|
||||
_add_file_to_context(context, filename)
|
||||
|
||||
|
||||
@given("I have added a non-existent file path to the analysis")
|
||||
def step_add_missing_file(context):
|
||||
_ensure_context_service(context)
|
||||
missing_path = context.temp_dir_path / "missing_file.py"
|
||||
context.created_files["missing_file.py"] = missing_path
|
||||
context.context_files.append(missing_path)
|
||||
context.nonexistent_path = missing_path
|
||||
|
||||
|
||||
@when("I analyze the context")
|
||||
def step_analyze_context(context):
|
||||
_ensure_context_service(context)
|
||||
context.analysis_result = context.context_service.analyze_context(context.project)
|
||||
|
||||
|
||||
@when("I analyze the context asynchronously")
|
||||
def step_analyze_context_async(context):
|
||||
_ensure_context_service(context)
|
||||
context.async_analysis_result = asyncio.run(
|
||||
context.context_service.analyze_context_async(context.project)
|
||||
)
|
||||
|
||||
|
||||
@when("I analyze the context with the non-existent path")
|
||||
def step_analyze_nonexistent(context):
|
||||
_ensure_context_service(context)
|
||||
context.nonexistent_result = context.context_service.analyze_context(
|
||||
context.project
|
||||
)
|
||||
|
||||
|
||||
@when("I stream the context analysis")
|
||||
def step_stream_context(context):
|
||||
_ensure_context_service(context)
|
||||
context.streaming_events = list(
|
||||
context.context_service.analyze_context_streaming(context.project)
|
||||
)
|
||||
|
||||
|
||||
@when("I stream the context analysis asynchronously")
|
||||
def step_stream_context_async(context):
|
||||
_ensure_context_service(context)
|
||||
|
||||
async def _collect_events() -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
async for event in context.context_service.analyze_context_streaming_async(
|
||||
context.project
|
||||
):
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
context.async_streaming_events = asyncio.run(_collect_events())
|
||||
|
||||
|
||||
@when("I get the context summary")
|
||||
def step_get_summary(context):
|
||||
_ensure_context_service(context)
|
||||
context.summary_text = context.context_service.get_context_summary(context.project)
|
||||
|
||||
|
||||
@when("I get the context dependencies")
|
||||
def step_get_dependencies(context):
|
||||
_ensure_context_service(context)
|
||||
context.dependency_map = context.context_service.get_context_dependencies(
|
||||
context.project
|
||||
)
|
||||
|
||||
|
||||
@when("I get relevant files with threshold {value:f}")
|
||||
def step_get_relevant_files(context, value: float):
|
||||
_ensure_context_service(context)
|
||||
context.relevant_files = context.context_service.get_relevant_files(
|
||||
context.project, threshold=value
|
||||
)
|
||||
|
||||
|
||||
@when('I prepare the LangGraph analysis config for run "{run_name}" in "{mode}" mode')
|
||||
def step_prepare_langsmith_config(context, run_name: str, mode: str):
|
||||
_ensure_context_service(context)
|
||||
context.langsmith_config = context.context_service._prepare_analysis_config(
|
||||
context.project,
|
||||
run_name=run_name,
|
||||
file_paths=[str(path) for path in context.context_files],
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
|
||||
@when("I request a context analysis agent")
|
||||
def step_request_context_agent(context):
|
||||
_ensure_context_service(context)
|
||||
context.requested_agent = context.context_service._get_context_agent()
|
||||
|
||||
|
||||
@then("the analysis result should have empty documents")
|
||||
def step_assert_empty_documents(context):
|
||||
docs = context.analysis_result["documents"]
|
||||
assert len(docs) == 0, "Expected no documents in the analysis result"
|
||||
|
||||
|
||||
@then("the analysis summary should indicate no files to analyze")
|
||||
def step_assert_empty_summary(context):
|
||||
summary = context.analysis_result["summary"]
|
||||
assert "no context files" in summary.lower(), summary
|
||||
|
||||
|
||||
@then("there should be no error in the analysis")
|
||||
def step_assert_no_error(context):
|
||||
assert context.analysis_result["error"] in (None, ""), context.analysis_result[
|
||||
"error"
|
||||
]
|
||||
|
||||
|
||||
@then("the analysis result should have {count:d} document")
|
||||
@then("the analysis result should have {count:d} documents")
|
||||
def step_assert_document_count(context, count: int):
|
||||
docs = context.analysis_result["documents"]
|
||||
assert len(docs) == count, f"Expected {count} documents, got {len(docs)}"
|
||||
|
||||
|
||||
@then('the dependencies for "{filename}" should include "{token}"')
|
||||
def step_assert_dependencies(context, filename: str, token: str):
|
||||
file_path = str(context.created_files[filename])
|
||||
dependencies = context.analysis_result["dependencies"].get(file_path, [])
|
||||
assert any(token in entry for entry in dependencies), dependencies
|
||||
|
||||
|
||||
@then('the relevance score for "{filename}" should be between {low:f} and {high:f}')
|
||||
def step_assert_relevance_range(context, filename: str, low: float, high: float):
|
||||
file_path = str(context.created_files[filename])
|
||||
score = context.analysis_result["relevance_scores"].get(file_path)
|
||||
assert score is not None, f"No score for {filename}"
|
||||
assert low <= score <= high, f"Score {score} not in range [{low}, {high}]"
|
||||
|
||||
|
||||
@then("the summary should not be empty")
|
||||
def step_assert_summary_not_empty(context):
|
||||
assert context.analysis_result["summary"].strip(), "Summary is empty"
|
||||
|
||||
|
||||
@then("the summary should be a non-empty string")
|
||||
def step_assert_summary_string(context):
|
||||
assert context.summary_text.strip(), "Summary text is empty"
|
||||
|
||||
|
||||
@then("the summary should not indicate an error")
|
||||
def step_assert_summary_no_error(context):
|
||||
assert "failed" not in context.summary_text.lower(), context.summary_text
|
||||
|
||||
|
||||
@then("the dependencies should include entries for both files")
|
||||
def step_assert_dependencies_two(context):
|
||||
dep_map = context.analysis_result["dependencies"]
|
||||
assert len(dep_map.keys()) >= 2, dep_map
|
||||
|
||||
|
||||
@then("the relevance scores should have entries for both files")
|
||||
def step_assert_scores_two(context):
|
||||
relevance = context.analysis_result["relevance_scores"]
|
||||
assert len(relevance.keys()) >= 2, relevance
|
||||
|
||||
|
||||
@then('the dependencies dict should have an entry for "{filename}"')
|
||||
def step_assert_dependency_entry(context, filename: str):
|
||||
file_path = str(context.created_files[filename])
|
||||
deps = context.dependency_map.get(file_path, [])
|
||||
assert deps, f"No dependencies recorded for {filename}"
|
||||
|
||||
|
||||
@then("the dependencies should be a non-empty list")
|
||||
def step_assert_dependency_list(context):
|
||||
assert all(context.dependency_map.values()), context.dependency_map
|
||||
|
||||
|
||||
@then("the result should include both files with scores")
|
||||
def step_assert_relevant_files(context):
|
||||
paths = {Path(path) for path, _ in context.relevant_files}
|
||||
expected = {path for path in context.context_files}
|
||||
assert expected.issubset(paths), f"Missing files in scores: {expected - paths}"
|
||||
|
||||
|
||||
@then("all LangGraph scores should be between {low:f} and {high:f}")
|
||||
def step_assert_all_scores(context, low: float, high: float):
|
||||
for _, score in context.relevant_files:
|
||||
assert low <= score <= high, f"Score {score} outside [{low}, {high}]"
|
||||
|
||||
|
||||
@then("I should receive multiple events")
|
||||
def step_assert_multiple_events(context):
|
||||
assert len(context.streaming_events) >= 2, context.streaming_events
|
||||
|
||||
|
||||
@then("the events should include node execution results")
|
||||
def step_assert_node_events(context):
|
||||
node_names = {
|
||||
"load_files",
|
||||
"analyze_dependencies",
|
||||
"chunk_documents",
|
||||
"score_relevance",
|
||||
"summarize_context",
|
||||
}
|
||||
assert any(
|
||||
isinstance(event, dict) and node_names.intersection(event.keys())
|
||||
for event in context.streaming_events
|
||||
), context.streaming_events
|
||||
|
||||
|
||||
@then("the streaming output should report no files to analyze")
|
||||
def step_assert_streaming_no_files(context):
|
||||
assert context.streaming_events == [
|
||||
{"type": "complete", "summary": "No context files to analyze"}
|
||||
], context.streaming_events
|
||||
|
||||
|
||||
@then("the async analysis result should have empty documents")
|
||||
def step_assert_async_empty_documents(context):
|
||||
docs = context.async_analysis_result["documents"]
|
||||
assert not docs, f"Expected no async documents, got {len(docs)} entries"
|
||||
|
||||
|
||||
@then("the async analysis summary should indicate no files to analyze")
|
||||
def step_assert_async_empty_summary(context):
|
||||
summary = context.async_analysis_result["summary"].lower()
|
||||
assert "no context files" in summary, summary
|
||||
|
||||
|
||||
@then("the async result should have documents")
|
||||
def step_assert_async_documents(context):
|
||||
assert context.async_analysis_result["documents"], "Expected async documents"
|
||||
|
||||
|
||||
@then("the async result should have a summary")
|
||||
def step_assert_async_summary(context):
|
||||
assert context.async_analysis_result["summary"].strip(), "Async summary empty"
|
||||
|
||||
|
||||
@then("there should be no error in the async result")
|
||||
def step_assert_async_no_error(context):
|
||||
assert context.async_analysis_result["error"] in (None, "")
|
||||
|
||||
|
||||
@then("the analysis should complete without raising an exception")
|
||||
def step_assert_nonexistent_completed(context):
|
||||
assert context.nonexistent_result is not None
|
||||
|
||||
|
||||
@then("the error field should contain file not found information")
|
||||
def step_assert_nonexistent_error(context):
|
||||
error = context.nonexistent_result["error"]
|
||||
assert error and "file not found" in error.lower(), error
|
||||
|
||||
|
||||
@then("I should receive a ContextAnalysisAgent instance")
|
||||
def step_assert_agent_instance(context):
|
||||
assert isinstance(context.requested_agent, ContextAnalysisAgent)
|
||||
|
||||
|
||||
@then("the agent should have the standard workflow nodes")
|
||||
def step_assert_agent_nodes(context):
|
||||
expected = {
|
||||
"load_files",
|
||||
"analyze_dependencies",
|
||||
"chunk_documents",
|
||||
"score_relevance",
|
||||
"summarize_context",
|
||||
}
|
||||
actual = set(context.requested_agent.graph.nodes.keys())
|
||||
assert expected.issubset(actual), actual
|
||||
|
||||
|
||||
@then("the summary should not indicate an error in the dependency run")
|
||||
def step_assert_dependency_summary(context):
|
||||
assert context.analysis_result["error"] in (None, "")
|
||||
|
||||
|
||||
@then('the dependencies should be a non-empty list for "{filename}"')
|
||||
def step_assert_dependencies_specific(context, filename: str):
|
||||
file_path = str(context.created_files[filename])
|
||||
deps = context.analysis_result["dependencies"].get(file_path, [])
|
||||
assert deps, f"Empty dependency list for {filename}"
|
||||
|
||||
|
||||
@then("I should receive node execution details in streaming events")
|
||||
def step_assert_streaming_details(context):
|
||||
node_names = {
|
||||
"load_files",
|
||||
"analyze_dependencies",
|
||||
"chunk_documents",
|
||||
"score_relevance",
|
||||
"summarize_context",
|
||||
}
|
||||
matched = [
|
||||
event
|
||||
for event in context.streaming_events
|
||||
if node_names.intersection(event.keys())
|
||||
]
|
||||
assert matched, context.streaming_events
|
||||
|
||||
|
||||
@then("the LangSmith config should include the current project metadata")
|
||||
def step_assert_langsmith_metadata(context):
|
||||
config = getattr(context, "langsmith_config", {})
|
||||
metadata = config.get("metadata", {})
|
||||
tags = config.get("tags", [])
|
||||
assert metadata.get("project_id") == context.project.id, metadata
|
||||
assert metadata.get("plan_id") == context.plan_metadata.id, metadata
|
||||
assert metadata.get("service") == "ContextService", metadata
|
||||
assert any(tag.startswith("project:") for tag in tags), tags
|
||||
|
||||
|
||||
@then("the LangSmith config should record {count:d} context file path")
|
||||
def step_assert_langsmith_count(context, count: int):
|
||||
config = getattr(context, "langsmith_config", {})
|
||||
metadata = config.get("metadata", {})
|
||||
assert metadata.get("context_file_count") == count, metadata
|
||||
|
||||
|
||||
@then("the async streaming events should include node execution results")
|
||||
def step_assert_async_streaming_events(context):
|
||||
node_names = {
|
||||
"load_files",
|
||||
"analyze_dependencies",
|
||||
"chunk_documents",
|
||||
"score_relevance",
|
||||
"summarize_context",
|
||||
}
|
||||
assert any(
|
||||
isinstance(event, dict) and node_names.intersection(event.keys())
|
||||
for event in context.async_streaming_events
|
||||
), context.async_streaming_events
|
||||
|
||||
|
||||
@then("the async streaming output should report no files to analyze")
|
||||
def step_assert_async_streaming_no_files(context):
|
||||
assert context.async_streaming_events == [
|
||||
{"type": "complete", "summary": "No context files to analyze"}
|
||||
], context.async_streaming_events
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from features.environment import LANGSMITH_ENV_VARS
|
||||
|
||||
from cleveragents.config import settings as settings_module
|
||||
|
||||
|
||||
def _ensure_env_tracking(context: Any) -> None:
|
||||
if not hasattr(context, "env_vars_to_clean"):
|
||||
context.env_vars_to_clean = []
|
||||
|
||||
|
||||
@given("LangSmith environment is clean")
|
||||
def step_clear_langsmith_env(context):
|
||||
_ensure_env_tracking(context)
|
||||
for key in LANGSMITH_ENV_VARS:
|
||||
os.environ.pop(key, None)
|
||||
context.env_vars_to_clean.clear()
|
||||
|
||||
|
||||
@given('I set environment variable "{name}" to "{value}"')
|
||||
def step_set_env_var(context, name: str, value: str):
|
||||
_ensure_env_tracking(context)
|
||||
os.environ[name] = value
|
||||
context.env_vars_to_clean.append(name)
|
||||
|
||||
|
||||
@when("I load CleverAgents settings for LangSmith")
|
||||
def step_load_settings(context):
|
||||
settings_module.Settings._instance = None
|
||||
context.langsmith_settings = settings_module.get_settings()
|
||||
|
||||
|
||||
@when('I build a LangSmith config with run name "{run_name}"')
|
||||
def step_build_langsmith_config(context, run_name: str):
|
||||
settings_obj = getattr(context, "langsmith_settings", None)
|
||||
assert settings_obj is not None, "LangSmith settings not loaded"
|
||||
context.langsmith_config = settings_obj.build_langsmith_config(
|
||||
tags=["context-analysis", "behave-test"],
|
||||
metadata={"scenario": "langsmith_config", "run_name": run_name},
|
||||
run_name=run_name,
|
||||
)
|
||||
|
||||
|
||||
@then("LangSmith tracing should be disabled")
|
||||
def step_assert_tracing_disabled(context):
|
||||
settings_obj = getattr(context, "langsmith_settings", None)
|
||||
assert settings_obj is not None, "LangSmith settings not loaded"
|
||||
assert not settings_obj.is_langsmith_enabled, "Tracing unexpectedly enabled"
|
||||
|
||||
|
||||
@then("LangSmith tracing should be enabled")
|
||||
def step_assert_tracing_enabled(context):
|
||||
settings_obj = getattr(context, "langsmith_settings", None)
|
||||
assert settings_obj is not None, "LangSmith settings not loaded"
|
||||
assert settings_obj.is_langsmith_enabled, "Tracing was not enabled"
|
||||
|
||||
|
||||
@then('the LangSmith config should include tag "{tag}"')
|
||||
def step_assert_config_tag(context, tag: str):
|
||||
config = getattr(context, "langsmith_config", None)
|
||||
assert config, "LangSmith config was not built"
|
||||
tags = config.get("tags", [])
|
||||
assert tag in tags, f"Expected tag '{tag}' in {tags}"
|
||||
|
||||
|
||||
@then('the LangSmith config should include metadata key "{key}"')
|
||||
def step_then_the_langsmith_config_should_include_metadata_key(context, key):
|
||||
config = getattr(context, "langsmith_config", None)
|
||||
assert config, "LangSmith config was not built"
|
||||
metadata = config.get("metadata", {})
|
||||
assert key in metadata, f"Expected metadata key '{key}' in {metadata}"
|
||||
@@ -997,13 +997,6 @@ def step_call_plan_helper_without_project(context):
|
||||
# Then steps for assertions
|
||||
|
||||
|
||||
@then('a CleverAgentsError should be raised with message "{message}"')
|
||||
def step_assert_cleveragents_error_message(context, message):
|
||||
"""Assert that a CleverAgentsError was raised with the expected message."""
|
||||
assert isinstance(context.call_exception, CleverAgentsError)
|
||||
assert str(context.call_exception) == message
|
||||
|
||||
|
||||
@then("the programmatic plan apply_command should report {count:d} applied changes")
|
||||
def step_assert_programmatic_apply_changes(context, count):
|
||||
"""Assert apply_command returned the expected change count."""
|
||||
|
||||
@@ -6,6 +6,7 @@ import tempfile
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
@@ -334,6 +335,164 @@ def step_check_plan_error(context: Context, message: str) -> None:
|
||||
assert message in str(context.exception.message)
|
||||
|
||||
|
||||
@given("I configured an auto-debug plan with a disappearing plan ID")
|
||||
def step_configure_auto_debug_disappearing_plan(context: Context) -> None:
|
||||
"""Set up a plan service whose current plan loses its ID mid-run."""
|
||||
assert hasattr(context, "temp_dir"), (
|
||||
"Temporary directory setup is required before configuring auto-debug"
|
||||
)
|
||||
|
||||
class FlickeringPlan:
|
||||
def __init__(self, plan_id: int) -> None:
|
||||
self._plan_id = plan_id
|
||||
self._first_access = True
|
||||
self.prompt = "Investigate flaky build"
|
||||
self.name = "auto-debug-plan"
|
||||
self.status = PlanStatus.PENDING
|
||||
self.current = True
|
||||
self.updated_at = datetime.now()
|
||||
self.result = None
|
||||
|
||||
@property
|
||||
def id(self) -> int | None:
|
||||
if self._first_access:
|
||||
self._first_access = False
|
||||
return self._plan_id
|
||||
return None
|
||||
|
||||
class FakePlansRepository:
|
||||
def __init__(self, plan):
|
||||
self.plan = plan
|
||||
|
||||
def get_current_for_project(self, project_id: int):
|
||||
return self.plan
|
||||
|
||||
class FakeContextsRepository:
|
||||
def __init__(self) -> None:
|
||||
self.called = False
|
||||
|
||||
def get_for_plan(self, plan_id: int):
|
||||
self.called = True
|
||||
return []
|
||||
|
||||
class FakeDebugAttemptsRepository:
|
||||
def __init__(self) -> None:
|
||||
self.records: list = []
|
||||
|
||||
def add(self, attempt):
|
||||
attempt.id = len(self.records) + 1
|
||||
self.records.append(attempt)
|
||||
return attempt
|
||||
|
||||
def get(self, attempt_id: int):
|
||||
return None
|
||||
|
||||
def update(self, attempt):
|
||||
self.updated_attempt = attempt
|
||||
|
||||
class FakeUnitOfWork:
|
||||
def __init__(self, contexts: list[SimpleNamespace]) -> None:
|
||||
self._contexts = contexts
|
||||
|
||||
def transaction(self):
|
||||
outer = self
|
||||
|
||||
class _Transaction:
|
||||
def __enter__(self_inner):
|
||||
if not outer._contexts:
|
||||
raise AssertionError("No fake transaction contexts remaining")
|
||||
return outer._contexts.pop(0)
|
||||
|
||||
def __exit__(self_inner, exc_type, exc_val, exc_tb):
|
||||
return False
|
||||
|
||||
return _Transaction()
|
||||
|
||||
flicker_plan = FlickeringPlan(plan_id=101)
|
||||
contexts_repo = FakeContextsRepository()
|
||||
debug_repo = FakeDebugAttemptsRepository()
|
||||
fake_contexts = [
|
||||
SimpleNamespace(plans=FakePlansRepository(flicker_plan)),
|
||||
SimpleNamespace(contexts=contexts_repo),
|
||||
SimpleNamespace(debug_attempts=debug_repo),
|
||||
]
|
||||
|
||||
context.auto_debug_context_repo = contexts_repo
|
||||
context.auto_debug_debug_repo = debug_repo
|
||||
context.plan_service = PlanService(
|
||||
settings=Settings(),
|
||||
unit_of_work=FakeUnitOfWork(fake_contexts),
|
||||
ai_provider=None,
|
||||
)
|
||||
context.project = Project(
|
||||
id=1,
|
||||
name="auto-debug-project",
|
||||
path=context.temp_dir,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
current_plan_id=1,
|
||||
settings=ProjectSettings(
|
||||
auto_build=False,
|
||||
auto_apply=False,
|
||||
confirm_apply=True,
|
||||
max_context_size=50 * 1024 * 1024,
|
||||
default_model="mock-gpt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@when("I run auto-debug build with missing plan context")
|
||||
def step_run_auto_debug_missing_context(context: Context) -> None:
|
||||
"""Trigger auto-debug build to exercise empty context handling."""
|
||||
context.plan_service.build_plan = MagicMock(
|
||||
side_effect=RuntimeError("Plan build failed")
|
||||
)
|
||||
captured_state: dict[str, object] = {}
|
||||
|
||||
class DummyAutoDebugAgent:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
def invoke(self, debug_state, config=None):
|
||||
captured_state["state"] = debug_state
|
||||
captured_state["config"] = config
|
||||
return {"result": {"success": False, "fix": None}}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
context.plan_service, "_prepare_langsmith_config", return_value={}
|
||||
),
|
||||
patch(
|
||||
"cleveragents.application.agents.auto_debug.AutoDebugAgent",
|
||||
DummyAutoDebugAgent,
|
||||
),
|
||||
):
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project, max_attempts=1
|
||||
)
|
||||
|
||||
context.auto_debug_captured_state = captured_state.get("state")
|
||||
|
||||
|
||||
@then("the auto-debug build should fall back to an empty context")
|
||||
def step_assert_auto_debug_empty_context(context: Context) -> None:
|
||||
"""Verify empty code context and debug attempt tracking."""
|
||||
result = getattr(context, "auto_debug_result", None)
|
||||
assert result is not None, "Auto-debug result was not captured"
|
||||
success, changes, error = result
|
||||
assert success is False
|
||||
assert changes == []
|
||||
assert error == "Plan build failed"
|
||||
|
||||
contexts_repo = getattr(context, "auto_debug_context_repo", None)
|
||||
assert contexts_repo is not None, "Contexts repository tracking missing"
|
||||
assert contexts_repo.called is False
|
||||
|
||||
debug_state = getattr(context, "auto_debug_captured_state", None)
|
||||
assert debug_state is not None, "Debug state was not recorded"
|
||||
assert debug_state.get("code_context") == ""
|
||||
|
||||
|
||||
@given("I have a saved project with current plan missing ID")
|
||||
def step_create_project_plan_no_id(context: Context) -> None:
|
||||
"""Create a project with current plan that has no ID."""
|
||||
@@ -725,6 +884,49 @@ def step_check_nested_move(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given("the plan has a pending MOVE change to an absolute path")
|
||||
def step_add_absolute_move_change(context: Context) -> None:
|
||||
"""Add a MOVE change whose destination is an absolute path."""
|
||||
assert getattr(context, "project", None) is not None, "Project is required"
|
||||
assert getattr(context, "current_plan", None) is not None, (
|
||||
"Current plan is required"
|
||||
)
|
||||
|
||||
source_path = context.project.path / "absolute_source.py"
|
||||
source_path.write_text("# Absolute move source")
|
||||
absolute_target = (context.project.path / "absolute_target.py").resolve()
|
||||
|
||||
with context.unit_of_work.transaction() as ctx:
|
||||
change = Change(
|
||||
id=None,
|
||||
plan_id=context.current_plan.id,
|
||||
file_path="absolute_source.py",
|
||||
operation=OperationType.MOVE,
|
||||
original_content="# Absolute move source",
|
||||
new_content="# Absolute move source",
|
||||
new_path=str(absolute_target),
|
||||
applied=False,
|
||||
applied_at=None,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
ctx.changes.add(change)
|
||||
|
||||
context.absolute_move_paths = (source_path, absolute_target)
|
||||
|
||||
|
||||
@then("the absolute destination should exist and the source should be removed")
|
||||
def step_check_absolute_move_destination(context: Context) -> None:
|
||||
"""Ensure MOVE changes create files at absolute targets."""
|
||||
paths = getattr(context, "absolute_move_paths", None)
|
||||
assert paths is not None, "Absolute move paths were not recorded"
|
||||
source_path, absolute_target = paths
|
||||
assert not source_path.exists(), "Source file still exists after MOVE"
|
||||
destination_path = Path(absolute_target)
|
||||
assert destination_path.exists(), "Absolute destination file missing"
|
||||
assert destination_path.read_text() == "# Absolute move source"
|
||||
assert getattr(context, "applied_count", 0) == 1
|
||||
|
||||
|
||||
@given("the plan has changes with absolute file paths")
|
||||
def step_add_absolute_path_changes(context: Context) -> None:
|
||||
"""Add changes with absolute file paths."""
|
||||
@@ -1444,3 +1646,257 @@ def step_verify_memory_service_reused(context: Context, session_id: str) -> None
|
||||
assert monitored_setter is not None
|
||||
assert monitored_setter.call_count == 0
|
||||
assert monitored_service.max_messages is not None
|
||||
|
||||
|
||||
@given("LangSmith integration is enabled for plan service")
|
||||
def step_enable_langsmith_integration(context: Context) -> None:
|
||||
"""Reconfigure the plan service with LangSmith support enabled."""
|
||||
|
||||
settings = Settings()
|
||||
settings.langsmith_enabled = True
|
||||
|
||||
original_builder = Settings.build_langsmith_config
|
||||
|
||||
def _forward_build_config(
|
||||
self: Settings, *, tags=None, metadata=None, run_name=None
|
||||
):
|
||||
return original_builder(
|
||||
self,
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
run_name=run_name,
|
||||
)
|
||||
|
||||
builder_patch = patch.object(
|
||||
Settings,
|
||||
"build_langsmith_config",
|
||||
autospec=True,
|
||||
side_effect=_forward_build_config,
|
||||
)
|
||||
builder_mock = builder_patch.start()
|
||||
cleanup = getattr(context, "add_cleanup", None)
|
||||
if callable(cleanup):
|
||||
cleanup(builder_patch.stop)
|
||||
else: # pragma: no cover - behave contexts should supply add_cleanup
|
||||
context._langsmith_builder_patch = builder_patch # type: ignore[attr-defined]
|
||||
context.langsmith_builder_mock = builder_mock
|
||||
|
||||
context.plan_service = PlanService(
|
||||
settings=settings,
|
||||
unit_of_work=context.unit_of_work,
|
||||
ai_provider=None,
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I prepare a LangSmith config for project "{project_name}" and plan "{plan_name}"'
|
||||
)
|
||||
def step_prepare_langsmith_config(
|
||||
context: Context, project_name: str, plan_name: str
|
||||
) -> None:
|
||||
"""Build and prepare a LangSmith config to capture metadata and tags."""
|
||||
|
||||
project = Project(
|
||||
id=101,
|
||||
name=project_name,
|
||||
path=context.temp_dir,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
current_plan_id=None,
|
||||
settings=ProjectSettings(
|
||||
auto_build=False,
|
||||
auto_apply=False,
|
||||
confirm_apply=True,
|
||||
max_context_size=50 * 1024 * 1024,
|
||||
default_model="langsmith",
|
||||
),
|
||||
)
|
||||
plan = Plan(
|
||||
id=202,
|
||||
project_id=project.id,
|
||||
name=plan_name,
|
||||
prompt="LangSmith coverage",
|
||||
status=PlanStatus.PENDING,
|
||||
current=True,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
build=None,
|
||||
build_started_at=None,
|
||||
build_completed_at=None,
|
||||
model_used=None,
|
||||
token_count=None,
|
||||
result=None,
|
||||
applied_at=None,
|
||||
files_created=None,
|
||||
files_modified=None,
|
||||
files_deleted=None,
|
||||
)
|
||||
config = context.plan_service._prepare_langsmith_config(
|
||||
project,
|
||||
plan,
|
||||
run_name="coverage",
|
||||
tags=["custom-tag"],
|
||||
metadata={"env": "test"},
|
||||
)
|
||||
context.langsmith_project = project
|
||||
context.langsmith_plan = plan
|
||||
context.langsmith_prepared_config = config
|
||||
builder_mock = getattr(context, "langsmith_builder_mock", None)
|
||||
if builder_mock is not None:
|
||||
context.langsmith_builder_call = builder_mock.call_args
|
||||
|
||||
|
||||
@then(
|
||||
'the LangSmith builder should receive metadata for project "{project_name}" and plan "{plan_name}"'
|
||||
)
|
||||
def step_assert_langsmith_builder_metadata(
|
||||
context: Context, project_name: str, plan_name: str
|
||||
) -> None:
|
||||
"""Verify the builder received merged metadata and tags."""
|
||||
|
||||
call = getattr(context, "langsmith_builder_call", None)
|
||||
assert call is not None, "LangSmith builder was not invoked"
|
||||
_, kwargs = call
|
||||
metadata = kwargs.get("metadata", {})
|
||||
tags = kwargs.get("tags", [])
|
||||
project = getattr(context, "langsmith_project", None)
|
||||
plan = getattr(context, "langsmith_plan", None)
|
||||
assert metadata.get("project_name") == project_name
|
||||
assert metadata.get("plan_name") == plan_name
|
||||
assert metadata.get("env") == "test"
|
||||
assert metadata.get("project_id") == getattr(project, "id", None)
|
||||
assert metadata.get("plan_id") == getattr(plan, "id", None)
|
||||
assert "service:plan" in tags
|
||||
assert f"project:{getattr(project, 'id', None)}" in tags
|
||||
assert f"plan:{getattr(plan, 'id', None)}" in tags
|
||||
assert "custom-tag" in tags
|
||||
|
||||
|
||||
@then("the prepared LangSmith config should include a generated thread id")
|
||||
def step_assert_langsmith_thread_id(context: Context) -> None:
|
||||
config = getattr(context, "langsmith_prepared_config", None)
|
||||
assert config, "LangSmith config was not prepared"
|
||||
configurable = config.get("configurable")
|
||||
assert isinstance(configurable, dict)
|
||||
thread_id = configurable.get("thread_id")
|
||||
assert isinstance(thread_id, str)
|
||||
assert thread_id.startswith("plan-service-")
|
||||
|
||||
|
||||
@given("I have a plan service with a failing AI provider")
|
||||
def step_plan_service_with_failing_provider(context: Context) -> None:
|
||||
"""Configure the plan service to surface provider failures."""
|
||||
|
||||
failing_provider = MagicMock()
|
||||
failing_provider.generate_changes.return_value = ProviderResponse(
|
||||
changes=[],
|
||||
model_used="mock-gpt",
|
||||
token_count=0,
|
||||
error_message="mock provider failure",
|
||||
)
|
||||
context.plan_service = PlanService(
|
||||
settings=Settings(),
|
||||
unit_of_work=context.unit_of_work,
|
||||
ai_provider=failing_provider,
|
||||
)
|
||||
context.mock_provider = failing_provider
|
||||
|
||||
|
||||
@when("I attempt to build the plan and the provider returns an error")
|
||||
def step_build_plan_with_provider_error(context: Context) -> None:
|
||||
"""Invoke build_plan and capture the provider error."""
|
||||
|
||||
try:
|
||||
context.plan_service.build_plan(context.project)
|
||||
context.exception = None
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
context.exception = exc
|
||||
|
||||
|
||||
@when("I run the auto debug build for the project")
|
||||
def step_run_auto_debug_without_plan(context: Context) -> None:
|
||||
"""Run auto_debug_build while capturing exceptions."""
|
||||
|
||||
try:
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project
|
||||
)
|
||||
context.exception = None
|
||||
except Exception as exc:
|
||||
context.auto_debug_result = None
|
||||
context.exception = exc
|
||||
|
||||
|
||||
@given("auto debug retries should run with a failing build pipeline")
|
||||
def step_force_auto_debug_failure(context: Context) -> None:
|
||||
"""Mark that auto_debug_build should simulate failing build attempts."""
|
||||
|
||||
context.force_auto_debug_failure = True
|
||||
|
||||
|
||||
@when("I run the auto debug build with max attempts {attempts:d}")
|
||||
def step_run_auto_debug_with_attempts(context: Context, attempts: int) -> None:
|
||||
"""Execute auto_debug_build, optionally forcing failures for coverage."""
|
||||
|
||||
def failing_build(
|
||||
self: PlanService,
|
||||
project: Project,
|
||||
progress_callback: Callable[[int], None] | None = None,
|
||||
) -> list[Change]:
|
||||
raise RuntimeError("Simulated auto debug failure")
|
||||
|
||||
agent_path = "cleveragents.application.agents.auto_debug.AutoDebugAgent"
|
||||
|
||||
try:
|
||||
if getattr(context, "force_auto_debug_failure", False):
|
||||
with patch.object(PlanService, "build_plan", side_effect=failing_build):
|
||||
with patch(agent_path) as agent_cls:
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.invoke.return_value = {
|
||||
"result": {"success": False, "fix": {}},
|
||||
}
|
||||
agent_cls.return_value = agent_instance
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project, max_attempts=attempts
|
||||
)
|
||||
else:
|
||||
context.auto_debug_result = context.plan_service.auto_debug_build(
|
||||
context.project, max_attempts=attempts
|
||||
)
|
||||
context.exception = None
|
||||
except Exception as exc:
|
||||
context.auto_debug_result = None
|
||||
context.exception = exc
|
||||
|
||||
|
||||
@then('the auto debug result should indicate failure with error message "{message}"')
|
||||
def step_assert_auto_debug_failure(context: Context, message: str) -> None:
|
||||
"""Validate that auto_debug_build returned a failure tuple."""
|
||||
|
||||
result = getattr(context, "auto_debug_result", None)
|
||||
assert result is not None, "Auto debug result was not captured"
|
||||
success, changes, error_message = result
|
||||
assert success is False
|
||||
assert isinstance(changes, list)
|
||||
assert isinstance(error_message, str)
|
||||
assert message in error_message
|
||||
|
||||
|
||||
@when("I try to stream plan generation without an AI provider")
|
||||
def step_stream_generate_without_provider(context: Context) -> None:
|
||||
"""Attempt to consume the streaming generator without configuring an AI provider."""
|
||||
|
||||
import asyncio
|
||||
|
||||
async def _consume_stream() -> None:
|
||||
generator = context.plan_service.generate_plan_streaming(
|
||||
context.project,
|
||||
description="Stream coverage scenario",
|
||||
)
|
||||
await generator.__anext__()
|
||||
|
||||
try:
|
||||
asyncio.run(_consume_stream())
|
||||
context.exception = None
|
||||
except Exception as exc:
|
||||
context.exception = exc
|
||||
|
||||
@@ -452,12 +452,6 @@ def step_check_suggestion(context, command):
|
||||
assert command in context.result.output
|
||||
|
||||
|
||||
@then("the command should exit with code {code:d}")
|
||||
def step_check_exit_code(context, code):
|
||||
"""Check specific exit code."""
|
||||
assert context.result.exit_code == code
|
||||
|
||||
|
||||
@then("the error message should be shown")
|
||||
def step_check_error_shown(context):
|
||||
"""Check error message shown."""
|
||||
|
||||
@@ -91,6 +91,21 @@ def step_check_environment(context, expected):
|
||||
assert context.settings.env == expected
|
||||
|
||||
|
||||
@when('I set the environment alias to "{value}"')
|
||||
def step_set_environment_alias(context, value):
|
||||
"""Update the environment alias property."""
|
||||
assert hasattr(context, "settings"), (
|
||||
"Settings must be loaded before updating the environment alias."
|
||||
)
|
||||
context.settings.environment = value
|
||||
|
||||
|
||||
@then('the environment alias should be "{expected}"')
|
||||
def step_check_environment_alias(context, expected):
|
||||
"""Validate the environment alias getter."""
|
||||
assert context.settings.environment == expected
|
||||
|
||||
|
||||
@then('the database URL should be "{expected}"')
|
||||
def step_check_database_url(context, expected):
|
||||
"""Check the database URL."""
|
||||
@@ -121,6 +136,30 @@ def step_set_data_directory(context, directory):
|
||||
context.test_data_dir = directory
|
||||
|
||||
|
||||
@given('the storage base path is "{directory}"')
|
||||
def step_set_storage_base_path(context, directory):
|
||||
"""Record the storage base directory used for tests."""
|
||||
context.storage_base_dir = directory
|
||||
|
||||
|
||||
@when('I compute the storage path for "{storage_type}"')
|
||||
def step_compute_storage_path(context, storage_type):
|
||||
"""Compute the storage path from the configured base."""
|
||||
base_dir = getattr(context, "storage_base_dir", None)
|
||||
assert base_dir is not None, "Storage base path must be set before computing it."
|
||||
settings = Settings.model_construct(storage_base_path=Path(base_dir))
|
||||
context.storage_path = settings.get_storage_base_path(storage_type)
|
||||
|
||||
|
||||
@when("I compute the default storage path")
|
||||
def step_compute_default_storage_path(context):
|
||||
"""Compute the storage path when no type is supplied."""
|
||||
base_dir = getattr(context, "storage_base_dir", None)
|
||||
assert base_dir is not None, "Storage base path must be set before computing it."
|
||||
settings = Settings.model_construct(storage_base_path=Path(base_dir))
|
||||
context.storage_path = settings.get_storage_base_path()
|
||||
|
||||
|
||||
@when('I get the storage base path for "{storage_type}"')
|
||||
def step_get_storage_path(context, storage_type):
|
||||
"""Get the storage base path."""
|
||||
@@ -151,6 +190,21 @@ def step_check_is_production(context):
|
||||
context.is_production = settings.is_production()
|
||||
|
||||
|
||||
@when(
|
||||
'I evaluate production mode with env "{env_value}", debug "{debug_flag}", and reload "{reload_flag}"'
|
||||
)
|
||||
def step_evaluate_production_mode_flags(context, env_value, debug_flag, reload_flag):
|
||||
"""Evaluate production helpers for the supplied flags."""
|
||||
Settings._instance = None
|
||||
settings = Settings.model_construct(
|
||||
env=env_value,
|
||||
debug_enabled=debug_flag.lower() == "true",
|
||||
server_reload=reload_flag.lower() == "true",
|
||||
)
|
||||
context.is_production = settings.is_production()
|
||||
context.is_production_mode = settings.is_production_mode
|
||||
|
||||
|
||||
@then("is_production should be {expected}")
|
||||
def step_verify_is_production(context, expected):
|
||||
"""Verify the is_production value."""
|
||||
@@ -158,6 +212,13 @@ def step_verify_is_production(context, expected):
|
||||
assert context.is_production == expected_bool
|
||||
|
||||
|
||||
@then("is_production_mode should be {expected}")
|
||||
def step_verify_is_production_mode(context, expected):
|
||||
"""Verify the is_production_mode alias."""
|
||||
expected_bool = expected == "True"
|
||||
assert context.is_production_mode == expected_bool
|
||||
|
||||
|
||||
@given('the {provider} API key is set to "{key}"')
|
||||
def step_set_provider_key(context, provider, key):
|
||||
"""Set a provider API key."""
|
||||
@@ -168,6 +229,13 @@ def step_set_provider_key(context, provider, key):
|
||||
context.env_vars_to_clean.append(env_key)
|
||||
|
||||
|
||||
@when('I instantiate settings with explicit openai API key "{value}"')
|
||||
def step_instantiate_settings_with_openai(context, value):
|
||||
"""Instantiate settings with a constructor-specified OpenAI key."""
|
||||
Settings._instance = None
|
||||
context.settings = Settings.model_construct(openai_api_key=value)
|
||||
|
||||
|
||||
@when('I check if "{provider}" provider is configured')
|
||||
def step_check_provider_configured(context, provider):
|
||||
"""Check if a provider is configured."""
|
||||
@@ -178,6 +246,16 @@ def step_check_provider_configured(context, provider):
|
||||
)
|
||||
|
||||
|
||||
@when('I clear the explicit "{provider}" key and recheck configuration')
|
||||
def step_clear_explicit_key_and_recheck(context, provider):
|
||||
"""Clear the in-memory provider key and re-evaluate configuration."""
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
attr = provider.lower().replace(" ", "_") + "_api_key"
|
||||
setattr(settings, attr, None)
|
||||
context.provider_configured = settings.has_provider_configured(provider)
|
||||
|
||||
|
||||
@then('has_provider_configured should be {expected} for "{provider}"')
|
||||
def step_verify_provider_configured(context, expected, provider):
|
||||
"""Verify provider configuration status."""
|
||||
@@ -243,6 +321,20 @@ def step_get_database_url(context):
|
||||
context.database_url = settings.get_database_url()
|
||||
|
||||
|
||||
@when('I derive the test database URL from "{url}"')
|
||||
def step_derive_test_database_url(context, url):
|
||||
"""Derive the fallback test database URL when none is provided."""
|
||||
Settings._instance = None
|
||||
settings = Settings.model_construct(database_url=url, test_database_url=None)
|
||||
context.derived_database_url = settings.get_database_url(test=True)
|
||||
|
||||
|
||||
@then('the derived test database URL should be "{expected}"')
|
||||
def step_check_derived_test_database_url(context, expected):
|
||||
"""Verify the derived test database URL."""
|
||||
assert context.derived_database_url == expected
|
||||
|
||||
|
||||
@when("I get the settings instance")
|
||||
def step_get_settings(context):
|
||||
"""Get the settings instance."""
|
||||
@@ -266,3 +358,17 @@ def step_check_provider_api_key(context, provider, expected):
|
||||
"""Check a provider API key value."""
|
||||
provider_key = provider.lower().replace(" ", "_") + "_api_key"
|
||||
assert getattr(context.settings, provider_key) == expected
|
||||
|
||||
|
||||
@when("I build the LangSmith config")
|
||||
def step_build_langsmith_config(context):
|
||||
"""Build the LangSmith configuration payload."""
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
context.langsmith_config = settings.build_langsmith_config()
|
||||
|
||||
|
||||
@then("the LangSmith config should be absent")
|
||||
def step_assert_langsmith_config_absent(context):
|
||||
"""Ensure LangSmith config is not generated when disabled."""
|
||||
assert context.langsmith_config is None
|
||||
|
||||
+10
-3
@@ -1509,6 +1509,12 @@ async def generate_plan_streaming(
|
||||
- Verified MockAIProvider already uses FakeListLLM (implementation complete)
|
||||
- All high-priority Phase 2 foundation tasks now complete
|
||||
|
||||
**2025-11-25 (Context Service Integration Validation):**
|
||||
- Verified `ContextService` now routes sync, async, and streaming analysis flows through `ContextAnalysisAgent` (`src/cleveragents/application/services/context_service.py:408`, `src/cleveragents/application/services/context_service.py:470`, `src/cleveragents/application/services/context_service.py:517`), confirming Stage 2.7.4 integration is production-ready.
|
||||
- Added Behave coverage for the service workflows via `features/context_service_analysis.feature:6` with dedicated steps in `features/steps/context_service_analysis_steps.py:66-344`, exercising file creation, dependency mapping, relevance thresholds, async runs, and streaming events.
|
||||
- Renamed the ambiguous step text to `I have added "{filename}" to the LangGraph context` in `features/steps/context_service_analysis_steps.py:88` to avoid clashing with the legacy step in `features/steps/service_steps.py:1004`.
|
||||
- Ran `nox -s unit_tests -- features/context_service_analysis.feature` to confirm all ten scenarios (75 steps) pass after the integration fixes.
|
||||
|
||||
**Deferred to Future Weeks:**
|
||||
- EntityMemory Integration (moved to Phase 3)
|
||||
- LangSmith observability setup (optional, can be done in Phase 6)
|
||||
@@ -4032,10 +4038,11 @@ If you can do all of the above by end of Day 1, you're on track!
|
||||
- [X] All tests passing (nox -s unit_tests, nox -s integration_tests)
|
||||
- [X] Code: Fix LangGraph checkpointing with thread_id in config
|
||||
- [ ] Code: Integrate agents into services
|
||||
- [ ] Update `ContextService` to use `ContextAnalysisAgent`
|
||||
- [ ] Add streaming support to context commands
|
||||
- [X] Update `ContextService` to use `ContextAnalysisAgent` (`src/cleveragents/application/services/context_service.py:408-515`).
|
||||
- [X] Add streaming support to context commands via `analyze_context_streaming*` methods (`src/cleveragents/application/services/context_service.py:517-596`).
|
||||
- [ ] Add LangSmith metadata for context analysis
|
||||
- [ ] Tests: Test end-to-end integration
|
||||
- [X] Tests: Test end-to-end integration
|
||||
- [X] Added service-level coverage in `features/context_service_analysis.feature:6` with steps from `features/steps/context_service_analysis_steps.py:66-344`, executed via `nox -s unit_tests -- features/context_service_analysis.feature`.
|
||||
- [X] Stage 2.7.5: Auto-Debug Agent Implementation (COMPLETE 2025-11-22)
|
||||
- [X] Code: Implement AutoDebugGraph
|
||||
- [X] Create `src/cleveragents/agents/auto_debug.py` (194 lines modified)
|
||||
|
||||
+14
-14
@@ -15,14 +15,14 @@ nox.options.error_on_external_run = True
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def lint(session):
|
||||
def lint(session: nox.Session):
|
||||
"""Check code formatting and linting."""
|
||||
session.install("ruff")
|
||||
session.run("ruff", "check", "src/", "scripts/", "examples/")
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def format(session):
|
||||
def format(session: nox.Session):
|
||||
"""Format code with ruff."""
|
||||
session.install("ruff")
|
||||
session.run("ruff", "format", ".")
|
||||
@@ -32,7 +32,7 @@ def format(session):
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def typecheck(session):
|
||||
def typecheck(session: nox.Session):
|
||||
"""Check types with pyright."""
|
||||
session.install("pyright")
|
||||
session.install("-e", ".")
|
||||
@@ -40,7 +40,7 @@ def typecheck(session):
|
||||
|
||||
|
||||
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
||||
def unit_tests(session):
|
||||
def unit_tests(session: nox.Session):
|
||||
"""Run BDD tests with Behave."""
|
||||
session.install("-e", ".[tests]")
|
||||
session.install("behave")
|
||||
@@ -76,28 +76,28 @@ def unit_tests(session):
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def docs(session):
|
||||
def docs(session: nox.Session):
|
||||
"""Build documentation with MkDocs."""
|
||||
session.install("-e", ".[docs]")
|
||||
session.run("mkdocs", "build")
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def serve_docs(session):
|
||||
def serve_docs(session: nox.Session):
|
||||
"""Serve docs locally for development."""
|
||||
session.install("-e", ".[docs]")
|
||||
session.run("mkdocs", "serve", "--dev-addr", "0.0.0.0:8000")
|
||||
|
||||
|
||||
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
||||
def build(session):
|
||||
def build(session: nox.Session):
|
||||
"""Build the wheel distribution."""
|
||||
session.install("build")
|
||||
session.run("python", "-m", "build", "--wheel")
|
||||
|
||||
|
||||
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
||||
def integration_tests(session):
|
||||
def integration_tests(session: nox.Session):
|
||||
"""Run Robot Framework integration tests (excluding discovery tests)."""
|
||||
session.install("-e", ".[tests]")
|
||||
|
||||
@@ -150,7 +150,7 @@ def integration_tests(session):
|
||||
|
||||
|
||||
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
||||
def slow_integration_tests(session):
|
||||
def slow_integration_tests(session: nox.Session):
|
||||
"""Run Robot Framework integration tests."""
|
||||
session.install("-e", ".[tests]")
|
||||
session.run(
|
||||
@@ -171,7 +171,7 @@ def slow_integration_tests(session):
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def coverage_report(session):
|
||||
def coverage_report(session: nox.Session):
|
||||
"""Generate coverage report from Behave tests."""
|
||||
session.install("-e", ".[tests]")
|
||||
|
||||
@@ -203,7 +203,7 @@ def coverage_report(session):
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def discovery_tests(session):
|
||||
def discovery_tests(session: nox.Session):
|
||||
"""Run Phase 0 discovery tests (tagged with @discovery)."""
|
||||
session.install("-e", ".[tests]")
|
||||
session.install("behave")
|
||||
@@ -221,7 +221,7 @@ def discovery_tests(session):
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def discovery_coverage(session):
|
||||
def discovery_coverage(session: nox.Session):
|
||||
"""Generate coverage report for discovery tests."""
|
||||
session.install("-e", ".[tests]")
|
||||
|
||||
@@ -252,7 +252,7 @@ def discovery_coverage(session):
|
||||
|
||||
|
||||
@nox.session(python=SUPPORTED_PYTHONS, reuse_venv=True, venv_backend="uv")
|
||||
def discovery_integration(session):
|
||||
def discovery_integration(session: nox.Session):
|
||||
"""Run Robot Framework integration tests tagged with 'discovery'."""
|
||||
session.install("-e", ".[tests]")
|
||||
|
||||
@@ -300,7 +300,7 @@ def discovery_integration(session):
|
||||
|
||||
|
||||
@nox.session(python=DEFAULT_PYTHON, reuse_venv=True, venv_backend="uv")
|
||||
def benchmark(session):
|
||||
def benchmark(session: nox.Session):
|
||||
"""Run Airspeed Velocity benchmarks and publish results."""
|
||||
session.install("-e", ".[tests]")
|
||||
config_path = "asv.conf.json"
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
This service handles adding, removing, and managing files that the AI
|
||||
will use as context when generating code changes. Uses repository pattern
|
||||
and Unit of Work for persistence (ADR-007).
|
||||
|
||||
Includes LangGraph-based context analysis for intelligent context processing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from datetime import datetime
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.core.exceptions import FileSystemError, PlanError
|
||||
@@ -18,6 +25,14 @@ from cleveragents.infrastructure.database.unit_of_work import (
|
||||
UnitOfWorkContext,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.language_models import BaseLanguageModel
|
||||
|
||||
from cleveragents.agents.context_analysis import (
|
||||
ContextAnalysisAgent,
|
||||
ContextAnalysisState,
|
||||
)
|
||||
|
||||
|
||||
class ContextService:
|
||||
"""Service for managing context files in plans.
|
||||
@@ -371,3 +386,358 @@ class ContextService:
|
||||
|
||||
contexts = self.list_context(project)
|
||||
return [context.path for context in contexts]
|
||||
|
||||
def _get_current_plan(self, project: Project) -> Plan | None:
|
||||
"""Fetch the current plan for metadata enrichment."""
|
||||
|
||||
if not project.id:
|
||||
return None
|
||||
with self.unit_of_work.transaction() as ctx:
|
||||
return ctx.plans.get_current_for_project(project.id)
|
||||
|
||||
def _build_langsmith_config(
|
||||
self,
|
||||
project: Project,
|
||||
*,
|
||||
run_name: str,
|
||||
file_paths: list[str],
|
||||
mode: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Construct metadata for LangSmith tracing if enabled."""
|
||||
|
||||
if not getattr(self.settings, "is_langsmith_enabled", False):
|
||||
return {}
|
||||
|
||||
plan = self._get_current_plan(project)
|
||||
metadata = {
|
||||
"project_id": project.id,
|
||||
"project_name": project.name,
|
||||
"plan_id": getattr(plan, "id", None),
|
||||
"plan_name": getattr(plan, "name", None),
|
||||
"context_file_count": len(file_paths),
|
||||
"service": "ContextService",
|
||||
"mode": mode,
|
||||
}
|
||||
tags = [
|
||||
"context-analysis",
|
||||
"service:context",
|
||||
f"mode:{mode}",
|
||||
]
|
||||
if project.id is not None:
|
||||
tags.append(f"project:{project.id}")
|
||||
return (
|
||||
self.settings.build_langsmith_config(
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
run_name=run_name,
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
def _prepare_analysis_config(
|
||||
self,
|
||||
project: Project,
|
||||
*,
|
||||
run_name: str,
|
||||
file_paths: list[str],
|
||||
mode: str,
|
||||
) -> dict[str, Any]:
|
||||
config = self._build_langsmith_config(
|
||||
project,
|
||||
run_name=run_name,
|
||||
file_paths=file_paths,
|
||||
mode=mode,
|
||||
)
|
||||
config.setdefault("configurable", {})
|
||||
config["configurable"]["thread_id"] = f"context-analysis-{uuid.uuid4()}"
|
||||
return config
|
||||
|
||||
# --- LangGraph-based Context Analysis Methods ---
|
||||
|
||||
def _get_context_agent(
|
||||
self, llm: BaseLanguageModel | None = None
|
||||
) -> ContextAnalysisAgent:
|
||||
"""Get or create a ContextAnalysisAgent instance.
|
||||
|
||||
Args:
|
||||
llm: Optional language model to use. If None, uses default mock.
|
||||
|
||||
Returns:
|
||||
ContextAnalysisAgent instance for analyzing context.
|
||||
"""
|
||||
# Import here to avoid circular dependency and allow lazy loading
|
||||
from cleveragents.agents.context_analysis import ContextAnalysisAgent
|
||||
|
||||
return ContextAnalysisAgent(llm=llm)
|
||||
|
||||
def analyze_context(
|
||||
self,
|
||||
project: Project,
|
||||
llm: BaseLanguageModel | None = None,
|
||||
) -> ContextAnalysisState:
|
||||
"""Analyze the current plan's context using LangGraph workflow.
|
||||
|
||||
Uses the ContextAnalysisAgent to perform intelligent analysis of the
|
||||
context files including dependency extraction, relevance scoring,
|
||||
and summarization.
|
||||
|
||||
Args:
|
||||
project: The project containing the plan
|
||||
llm: Optional language model to use for analysis
|
||||
|
||||
Returns:
|
||||
ContextAnalysisState containing analysis results including:
|
||||
- documents: Loaded documents
|
||||
- dependencies: Extracted dependencies per file
|
||||
- relevance_scores: Relevance scores for each file
|
||||
- chunks: Chunked documents for large files
|
||||
- summary: High-level summary of the context
|
||||
- error: Error message if any
|
||||
|
||||
Raises:
|
||||
PlanError: If no current plan exists
|
||||
"""
|
||||
# Get file paths from current context
|
||||
file_paths = self.list_files(project)
|
||||
|
||||
if not file_paths:
|
||||
# Return empty analysis for empty context
|
||||
from cleveragents.agents.context_analysis import ContextAnalysisState
|
||||
|
||||
return ContextAnalysisState(
|
||||
file_paths=[],
|
||||
documents=[],
|
||||
dependencies={},
|
||||
summary="No context files to analyze",
|
||||
relevance_scores={},
|
||||
chunks=[],
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Create agent and run analysis
|
||||
agent = self._get_context_agent(llm)
|
||||
config = self._prepare_analysis_config(
|
||||
project,
|
||||
run_name="ContextService.analyze_context",
|
||||
file_paths=file_paths,
|
||||
mode="sync",
|
||||
)
|
||||
|
||||
initial_state: ContextAnalysisState = {
|
||||
"file_paths": file_paths,
|
||||
"documents": [],
|
||||
"dependencies": {},
|
||||
"summary": "",
|
||||
"relevance_scores": {},
|
||||
"chunks": [],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
return agent.invoke(initial_state, config)
|
||||
|
||||
async def analyze_context_async(
|
||||
self,
|
||||
project: Project,
|
||||
llm: BaseLanguageModel | None = None,
|
||||
) -> ContextAnalysisState:
|
||||
"""Asynchronously analyze the current plan's context.
|
||||
|
||||
Async version of analyze_context for non-blocking execution.
|
||||
|
||||
Args:
|
||||
project: The project containing the plan
|
||||
llm: Optional language model to use for analysis
|
||||
|
||||
Returns:
|
||||
ContextAnalysisState containing analysis results
|
||||
"""
|
||||
from cleveragents.agents.context_analysis import ContextAnalysisState
|
||||
|
||||
file_paths = self.list_files(project)
|
||||
|
||||
if not file_paths:
|
||||
return ContextAnalysisState(
|
||||
file_paths=[],
|
||||
documents=[],
|
||||
dependencies={},
|
||||
summary="No context files to analyze",
|
||||
relevance_scores={},
|
||||
chunks=[],
|
||||
error=None,
|
||||
)
|
||||
|
||||
agent = self._get_context_agent(llm)
|
||||
config = self._prepare_analysis_config(
|
||||
project,
|
||||
run_name="ContextService.analyze_context_async",
|
||||
file_paths=file_paths,
|
||||
mode="async",
|
||||
)
|
||||
|
||||
initial_state: ContextAnalysisState = {
|
||||
"file_paths": file_paths,
|
||||
"documents": [],
|
||||
"dependencies": {},
|
||||
"summary": "",
|
||||
"relevance_scores": {},
|
||||
"chunks": [],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
return await agent.ainvoke(initial_state, config)
|
||||
|
||||
def analyze_context_streaming(
|
||||
self,
|
||||
project: Project,
|
||||
llm: BaseLanguageModel | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""Stream the context analysis workflow execution.
|
||||
|
||||
Yields events as each workflow node completes, allowing for
|
||||
real-time progress tracking.
|
||||
|
||||
Args:
|
||||
project: The project containing the plan
|
||||
llm: Optional language model to use for analysis
|
||||
|
||||
Yields:
|
||||
Dictionary containing node execution events
|
||||
"""
|
||||
file_paths = self.list_files(project)
|
||||
|
||||
if not file_paths:
|
||||
yield {"type": "complete", "summary": "No context files to analyze"}
|
||||
return
|
||||
|
||||
agent = self._get_context_agent(llm)
|
||||
config = self._prepare_analysis_config(
|
||||
project,
|
||||
run_name="ContextService.analyze_context_streaming",
|
||||
file_paths=file_paths,
|
||||
mode="stream",
|
||||
)
|
||||
|
||||
from cleveragents.agents.graphs.context_analysis import ContextAnalysisState
|
||||
|
||||
initial_state = ContextAnalysisState(
|
||||
file_paths=file_paths,
|
||||
documents=[],
|
||||
dependencies={},
|
||||
summary="",
|
||||
relevance_scores={},
|
||||
chunks=[],
|
||||
error=None,
|
||||
)
|
||||
|
||||
yield from agent.stream(initial_state, config)
|
||||
|
||||
async def analyze_context_streaming_async(
|
||||
self,
|
||||
project: Project,
|
||||
llm: BaseLanguageModel | None = None,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Asynchronously stream the context analysis workflow execution.
|
||||
|
||||
Async version of analyze_context_streaming for non-blocking streaming.
|
||||
|
||||
Args:
|
||||
project: The project containing the plan
|
||||
llm: Optional language model to use for analysis
|
||||
|
||||
Yields:
|
||||
Dictionary containing node execution events
|
||||
"""
|
||||
file_paths = self.list_files(project)
|
||||
|
||||
if not file_paths:
|
||||
yield {"type": "complete", "summary": "No context files to analyze"}
|
||||
return
|
||||
|
||||
agent = self._get_context_agent(llm)
|
||||
config = self._prepare_analysis_config(
|
||||
project,
|
||||
run_name="ContextService.analyze_context_streaming_async",
|
||||
file_paths=file_paths,
|
||||
mode="stream",
|
||||
)
|
||||
|
||||
from cleveragents.agents.graphs.context_analysis import ContextAnalysisState
|
||||
|
||||
initial_state = ContextAnalysisState(
|
||||
file_paths=file_paths,
|
||||
documents=[],
|
||||
dependencies={},
|
||||
summary="",
|
||||
relevance_scores={},
|
||||
chunks=[],
|
||||
error=None,
|
||||
)
|
||||
|
||||
async for event in agent.astream(initial_state, config):
|
||||
yield event
|
||||
|
||||
def get_context_summary(
|
||||
self,
|
||||
project: Project,
|
||||
llm: BaseLanguageModel | None = None,
|
||||
) -> str:
|
||||
"""Get a high-level summary of the current context.
|
||||
|
||||
Convenience method that runs the full analysis and returns
|
||||
just the summary.
|
||||
|
||||
Args:
|
||||
project: The project containing the plan
|
||||
llm: Optional language model to use for analysis
|
||||
|
||||
Returns:
|
||||
Summary string describing the context
|
||||
"""
|
||||
result = self.analyze_context(project, llm)
|
||||
return result["summary"] if result["summary"] else "No summary available"
|
||||
|
||||
def get_context_dependencies(
|
||||
self,
|
||||
project: Project,
|
||||
llm: BaseLanguageModel | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Get extracted dependencies for all context files.
|
||||
|
||||
Convenience method that runs the full analysis and returns
|
||||
just the dependency information.
|
||||
|
||||
Args:
|
||||
project: The project containing the plan
|
||||
llm: Optional language model to use for analysis
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to their dependencies
|
||||
"""
|
||||
result = self.analyze_context(project, llm)
|
||||
return result["dependencies"]
|
||||
|
||||
def get_relevant_files(
|
||||
self,
|
||||
project: Project,
|
||||
threshold: float = 0.5,
|
||||
llm: BaseLanguageModel | None = None,
|
||||
) -> list[tuple[str, float]]:
|
||||
"""Get context files sorted by relevance score.
|
||||
|
||||
Filters and sorts context files by their relevance scores.
|
||||
|
||||
Args:
|
||||
project: The project containing the plan
|
||||
threshold: Minimum relevance score (0.0 to 1.0)
|
||||
llm: Optional language model to use for analysis
|
||||
|
||||
Returns:
|
||||
List of (file_path, score) tuples sorted by score descending
|
||||
"""
|
||||
result = self.analyze_context(project, llm)
|
||||
scores = result["relevance_scores"]
|
||||
|
||||
filtered = [
|
||||
(path, score) for path, score in scores.items() if score >= threshold
|
||||
]
|
||||
return sorted(filtered, key=lambda x: x[1], reverse=True)
|
||||
|
||||
@@ -7,6 +7,7 @@ for persistence (ADR-007).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -135,6 +136,70 @@ class PlanService:
|
||||
if service and forget_history:
|
||||
service.clear()
|
||||
|
||||
def _build_langsmith_config(
|
||||
self,
|
||||
project: Project,
|
||||
plan: Plan | None,
|
||||
*,
|
||||
run_name: str,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build base LangSmith configuration with project/plan metadata."""
|
||||
|
||||
if not getattr(self.settings, "is_langsmith_enabled", False):
|
||||
return {}
|
||||
|
||||
base_metadata: dict[str, Any] = {
|
||||
"project_id": project.id,
|
||||
"project_name": project.name,
|
||||
}
|
||||
if plan and plan.id:
|
||||
base_metadata["plan_id"] = plan.id
|
||||
base_metadata["plan_name"] = plan.name
|
||||
if metadata:
|
||||
base_metadata |= metadata
|
||||
|
||||
base_tags = ["service:plan"]
|
||||
if project.id is not None:
|
||||
base_tags.append(f"project:{project.id}")
|
||||
if plan and plan.id is not None:
|
||||
base_tags.append(f"plan:{plan.id}")
|
||||
if tags:
|
||||
base_tags.extend(tags)
|
||||
|
||||
return (
|
||||
self.settings.build_langsmith_config(
|
||||
tags=base_tags,
|
||||
metadata=base_metadata,
|
||||
run_name=run_name,
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
def _prepare_langsmith_config(
|
||||
self,
|
||||
project: Project,
|
||||
plan: Plan | None,
|
||||
*,
|
||||
run_name: str,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
thread_prefix: str = "plan-service",
|
||||
) -> dict[str, Any]:
|
||||
config = self._build_langsmith_config(
|
||||
project,
|
||||
plan,
|
||||
run_name=run_name,
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
)
|
||||
if not config:
|
||||
return {}
|
||||
config.setdefault("configurable", {})
|
||||
config["configurable"]["thread_id"] = f"{thread_prefix}-{uuid.uuid4()}"
|
||||
return config
|
||||
|
||||
def create_plan(
|
||||
self, project: Project, prompt: str, name: str | None = None
|
||||
) -> Plan:
|
||||
@@ -405,8 +470,20 @@ class PlanService:
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
# Run debug workflow
|
||||
final_state = agent.invoke(debug_state)
|
||||
# Run debug workflow with LangSmith metadata when enabled
|
||||
config = self._prepare_langsmith_config(
|
||||
project,
|
||||
current_plan,
|
||||
run_name=f"PlanService.auto_debug_build.attempt_{attempt_number}",
|
||||
tags=["auto-debug", f"attempt:{attempt_number}"],
|
||||
metadata={
|
||||
"attempt_number": attempt_number,
|
||||
"max_attempts": max_attempts,
|
||||
"last_error": last_error,
|
||||
},
|
||||
thread_prefix="auto-debug",
|
||||
)
|
||||
final_state = agent.invoke(debug_state, config or None)
|
||||
|
||||
# Extract the fix result
|
||||
result = final_state.get("result", {})
|
||||
|
||||
@@ -147,6 +147,9 @@ def run(
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, (PlanError, CleverAgentsError)):
|
||||
raise
|
||||
|
||||
last_error = str(e)
|
||||
elapsed = time() - attempt_start
|
||||
|
||||
|
||||
+241
-138
@@ -1,167 +1,270 @@
|
||||
"""Settings management for CleverAgents.
|
||||
|
||||
Based on ADR-006: CLEVERAGENTS Environment Variable Management.
|
||||
All application variables use CLEVERAGENTS_ prefix.
|
||||
Provider variables (OPENAI_API_KEY, etc.) remain unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""CleverAgents configuration from environment variables.
|
||||
|
||||
All variables use CLEVERAGENTS_ prefix except provider keys.
|
||||
Configuration hierarchy: defaults -> env -> file -> CLI
|
||||
"""
|
||||
"""Application runtime configuration backed by environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLEVERAGENTS_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
validate_default=True,
|
||||
extra="forbid", # Fail on unknown variables
|
||||
env_prefix="cleveragents_", case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
||||
# Server Settings (CLEVERAGENTS_SERVER_*)
|
||||
server_host: str = "0.0.0.0" # CLEVERAGENTS_SERVER_HOST
|
||||
server_port: int = Field(default=8080, ge=1, le=65535) # CLEVERAGENTS_SERVER_PORT
|
||||
server_workers: int = Field(default=4, ge=1, le=100) # CLEVERAGENTS_SERVER_WORKERS
|
||||
server_reload: bool = False # CLEVERAGENTS_SERVER_RELOAD
|
||||
_instance: ClassVar[Settings | None] = None
|
||||
_PROVIDER_ENV_MAP: ClassVar[dict[str, tuple[str, ...]]] = {
|
||||
"openai_api_key": ("OPENAI_API_KEY",),
|
||||
"anthropic_api_key": ("ANTHROPIC_API_KEY",),
|
||||
"google_api_key": ("GOOGLE_API_KEY", "GOOGLE_GENAI_API_KEY"),
|
||||
"azure_api_key": ("AZURE_OPENAI_API_KEY", "AZURE_API_KEY"),
|
||||
"openrouter_api_key": ("OPENROUTER_API_KEY",),
|
||||
"gemini_api_key": ("GEMINI_API_KEY", "GOOGLE_GEMINI_API_KEY"),
|
||||
"hf_token": ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN"),
|
||||
"cohere_api_key": ("COHERE_API_KEY",),
|
||||
"perplexity_api_key": ("PERPLEXITY_API_KEY",),
|
||||
"groq_api_key": ("GROQ_API_KEY",),
|
||||
"together_api_key": ("TOGETHER_API_KEY",),
|
||||
}
|
||||
_PROVIDER_ALIAS: ClassVar[dict[str, str]] = {
|
||||
"openai": "openai_api_key",
|
||||
"anthropic": "anthropic_api_key",
|
||||
"google": "google_api_key",
|
||||
"azure": "azure_api_key",
|
||||
"openrouter": "openrouter_api_key",
|
||||
"router": "openrouter_api_key",
|
||||
"gemini": "gemini_api_key",
|
||||
"huggingface": "hf_token",
|
||||
"hf": "hf_token",
|
||||
"cohere": "cohere_api_key",
|
||||
"perplexity": "perplexity_api_key",
|
||||
"groq": "groq_api_key",
|
||||
"together": "together_api_key",
|
||||
}
|
||||
|
||||
# Database Settings (CLEVERAGENTS_DATABASE_*)
|
||||
database_url: str = "sqlite:///cleveragents.db" # CLEVERAGENTS_DATABASE_URL
|
||||
database_pool_size: int = Field(
|
||||
default=10, ge=1, le=100
|
||||
) # CLEVERAGENTS_DATABASE_POOL_SIZE
|
||||
database_pool_timeout: float = Field(
|
||||
default=30.0, gt=0
|
||||
) # CLEVERAGENTS_DATABASE_POOL_TIMEOUT
|
||||
database_echo: bool = False # CLEVERAGENTS_DATABASE_ECHO
|
||||
|
||||
# Cache Settings (CLEVERAGENTS_CACHE_*)
|
||||
cache_backend: str = Field(
|
||||
default="memory", pattern="^(memory|redis|sqlite)$"
|
||||
) # CLEVERAGENTS_CACHE_BACKEND
|
||||
cache_ttl: int = Field(default=3600, ge=0) # CLEVERAGENTS_CACHE_TTL
|
||||
cache_redis_url: str | None = None # CLEVERAGENTS_CACHE_REDIS_URL
|
||||
|
||||
# Auth Settings (CLEVERAGENTS_AUTH_*)
|
||||
auth_enabled: bool = True # CLEVERAGENTS_AUTH_ENABLED
|
||||
auth_session_timeout: int = Field(
|
||||
default=86400, ge=60
|
||||
) # CLEVERAGENTS_AUTH_SESSION_TIMEOUT
|
||||
auth_jwt_secret: str | None = None # CLEVERAGENTS_AUTH_JWT_SECRET
|
||||
auth_allow_registration: bool = True # CLEVERAGENTS_AUTH_ALLOW_REGISTRATION
|
||||
|
||||
# Storage Settings (CLEVERAGENTS_STORAGE_*)
|
||||
storage_path: Path = Path.home() / ".cleveragents" # CLEVERAGENTS_STORAGE_PATH
|
||||
storage_plans_dir: str = "plans" # CLEVERAGENTS_STORAGE_PLANS_DIR
|
||||
storage_contexts_dir: str = "contexts" # CLEVERAGENTS_STORAGE_CONTEXTS_DIR
|
||||
storage_temp_dir: str = "tmp" # CLEVERAGENTS_STORAGE_TEMP_DIR
|
||||
|
||||
# Model Settings (CLEVERAGENTS_MODEL_*)
|
||||
model_default_chat: str = "gpt-4" # CLEVERAGENTS_MODEL_DEFAULT_CHAT
|
||||
model_default_embedding: str = (
|
||||
"text-embedding-ada-002" # CLEVERAGENTS_MODEL_DEFAULT_EMBEDDING
|
||||
# Runtime/server configuration
|
||||
env: str = Field(
|
||||
default="development",
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_ENV"),
|
||||
)
|
||||
server_host: str = Field(
|
||||
default="0.0.0.0",
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SERVER_HOST"),
|
||||
)
|
||||
server_port: int = Field(
|
||||
default=8080,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SERVER_PORT"),
|
||||
)
|
||||
server_reload: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SERVER_RELOAD"),
|
||||
)
|
||||
debug_enabled: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_DEBUG_ENABLED"),
|
||||
)
|
||||
model_timeout: float = Field(default=120.0, gt=0) # CLEVERAGENTS_MODEL_TIMEOUT
|
||||
model_max_retries: int = Field(
|
||||
default=3, ge=0, le=10
|
||||
) # CLEVERAGENTS_MODEL_MAX_RETRIES
|
||||
|
||||
# Telemetry Settings (CLEVERAGENTS_TELEMETRY_*)
|
||||
telemetry_enabled: bool = False # CLEVERAGENTS_TELEMETRY_ENABLED
|
||||
telemetry_endpoint: str | None = None # CLEVERAGENTS_TELEMETRY_ENDPOINT
|
||||
telemetry_sample_rate: float = Field(
|
||||
default=1.0, ge=0.0, le=1.0
|
||||
) # CLEVERAGENTS_TELEMETRY_SAMPLE_RATE
|
||||
|
||||
# Debug Settings (CLEVERAGENTS_DEBUG_*)
|
||||
debug_enabled: bool = False # CLEVERAGENTS_DEBUG_ENABLED
|
||||
# Logging/paths
|
||||
log_level: str = Field(
|
||||
default="INFO",
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_LOG_LEVEL"),
|
||||
)
|
||||
debug_log_level: str = Field(
|
||||
default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$"
|
||||
) # CLEVERAGENTS_DEBUG_LOG_LEVEL
|
||||
debug_log_format: str = Field(
|
||||
default="json", pattern="^(json|text)$"
|
||||
) # CLEVERAGENTS_DEBUG_LOG_FORMAT
|
||||
debug_trace_enabled: bool = False # CLEVERAGENTS_DEBUG_TRACE_ENABLED
|
||||
default="INFO",
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_DEBUG_LOG_LEVEL"),
|
||||
)
|
||||
log_dir: Path = Field(
|
||||
default_factory=lambda: Path("logs"),
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_LOG_DIR"),
|
||||
)
|
||||
data_dir: Path = Field(
|
||||
default_factory=lambda: Path("data"),
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_DATA_DIR"),
|
||||
)
|
||||
storage_base_path: Path = Field(
|
||||
default_factory=lambda: Path("data"),
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_STORAGE_BASE_PATH"),
|
||||
)
|
||||
|
||||
# Testing Settings (CLEVERAGENTS_TESTING_*)
|
||||
testing_use_mock_ai: bool = False # CLEVERAGENTS_TESTING_USE_MOCK_AI
|
||||
# Persistence
|
||||
database_url: str = Field(
|
||||
default="sqlite:///cleveragents.db",
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_DATABASE_URL"),
|
||||
)
|
||||
test_database_url: str = Field(
|
||||
default="sqlite:///cleveragents_test.db",
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_TEST_DATABASE_URL"),
|
||||
)
|
||||
|
||||
# Provider API Keys (no prefix)
|
||||
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
|
||||
anthropic_api_key: str | None = Field(default=None, alias="ANTHROPIC_API_KEY")
|
||||
google_api_key: str | None = Field(default=None, alias="GOOGLE_API_KEY")
|
||||
azure_api_key: str | None = Field(default=None, alias="AZURE_API_KEY")
|
||||
openrouter_api_key: str | None = Field(default=None, alias="OPENROUTER_API_KEY")
|
||||
gemini_api_key: str | None = Field(default=None, alias="GEMINI_API_KEY")
|
||||
hf_token: str | None = Field(default=None, alias="HF_TOKEN")
|
||||
# LangSmith
|
||||
langsmith_enabled: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_LANGSMITH_ENABLED"),
|
||||
)
|
||||
langsmith_api_key: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_LANGSMITH_API_KEY"),
|
||||
)
|
||||
langsmith_project: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_LANGSMITH_PROJECT"),
|
||||
)
|
||||
langsmith_tags: list[str] = Field(
|
||||
default_factory=list,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_LANGSMITH_TAGS"),
|
||||
)
|
||||
|
||||
# Provider keys (populated from both prefixed and provider env vars)
|
||||
openai_api_key: str | None = Field(default=None)
|
||||
anthropic_api_key: str | None = Field(default=None)
|
||||
google_api_key: str | None = Field(default=None)
|
||||
azure_api_key: str | None = Field(default=None)
|
||||
openrouter_api_key: str | None = Field(default=None)
|
||||
gemini_api_key: str | None = Field(default=None)
|
||||
hf_token: str | None = Field(default=None)
|
||||
cohere_api_key: str | None = Field(default=None)
|
||||
perplexity_api_key: str | None = Field(default=None)
|
||||
groq_api_key: str | None = Field(default=None)
|
||||
together_api_key: str | None = Field(default=None)
|
||||
|
||||
def model_post_init(self, __context: Any) -> None: # type: ignore[override]
|
||||
super().model_post_init(__context)
|
||||
self._apply_external_env_overrides()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Singleton helpers
|
||||
# ------------------------------------------------------------------
|
||||
@classmethod
|
||||
def get_settings(cls: type[Settings]) -> Settings:
|
||||
"""Return the cached singleton instance (creating it if needed)."""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Derived properties & helpers
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def environment(self) -> str:
|
||||
"""Backwards compatible alias for ``env``."""
|
||||
return self.env
|
||||
|
||||
@environment.setter
|
||||
def environment(self, value: str) -> None:
|
||||
self.env = value
|
||||
|
||||
@property
|
||||
def storage_base_path(self) -> Path:
|
||||
"""Computed property for base storage path."""
|
||||
return self.storage_path / "data"
|
||||
def storage_path(self) -> Path:
|
||||
"""Absolute path backing runtime storage."""
|
||||
base = self.storage_base_path
|
||||
if not base.is_absolute():
|
||||
base = Path.cwd() / base
|
||||
return base.resolve()
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
"""Check if running in production mode."""
|
||||
return not self.debug_enabled and not self.server_reload
|
||||
def get_storage_base_path(self, storage_type: str | None = None) -> Path:
|
||||
"""Return the base storage path optionally scoped by type."""
|
||||
if storage_type:
|
||||
storage_type = storage_type.strip()
|
||||
if storage_type:
|
||||
return self.storage_path / storage_type
|
||||
return self.storage_path
|
||||
|
||||
def get_database_url(self, test: bool = False) -> str:
|
||||
"""Get database URL with test suffix if needed.
|
||||
|
||||
Args:
|
||||
test: If True, return test database URL
|
||||
|
||||
Returns:
|
||||
Database URL string
|
||||
"""
|
||||
if test and "sqlite" in self.database_url:
|
||||
return self.database_url.replace(".db", "_test.db")
|
||||
def get_database_url(self, *, test: bool = False) -> str:
|
||||
"""Return the primary or test database URL."""
|
||||
if test:
|
||||
if self.test_database_url:
|
||||
return self.test_database_url
|
||||
return self._derive_test_database_url(self.database_url)
|
||||
return self.database_url
|
||||
|
||||
def has_provider_configured(self) -> bool:
|
||||
"""Check if at least one AI provider is configured.
|
||||
|
||||
Returns:
|
||||
True if any provider API key is set
|
||||
"""
|
||||
return any(
|
||||
[
|
||||
self.openai_api_key,
|
||||
self.anthropic_api_key,
|
||||
self.google_api_key,
|
||||
self.azure_api_key,
|
||||
self.openrouter_api_key,
|
||||
self.gemini_api_key,
|
||||
self.hf_token,
|
||||
]
|
||||
def is_production(self) -> bool:
|
||||
"""Whether the runtime matches production settings."""
|
||||
return (
|
||||
not self.debug_enabled
|
||||
and not self.server_reload
|
||||
and self.env.lower() == "production"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_production_mode(self) -> bool:
|
||||
"""Convenience alias for :meth:`is_production`."""
|
||||
return self.is_production()
|
||||
|
||||
# Global settings instance (lazy loaded)
|
||||
_settings: Settings | None = None
|
||||
@property
|
||||
def is_langsmith_enabled(self) -> bool:
|
||||
"""Check if LangSmith tracing is enabled."""
|
||||
if self.langsmith_enabled:
|
||||
return True
|
||||
return os.getenv("LANGCHAIN_TRACING_V2", "false").lower() == "true"
|
||||
|
||||
def has_provider_configured(self, provider: str | None = None) -> bool:
|
||||
"""Return True when any (or a specific) provider has credentials configured."""
|
||||
if provider:
|
||||
attr = self._PROVIDER_ALIAS.get(provider.strip().lower())
|
||||
if not attr:
|
||||
return False
|
||||
return bool(self._provider_value(attr))
|
||||
|
||||
return any(self._provider_value(attr) for attr in self._PROVIDER_ENV_MAP)
|
||||
|
||||
def build_langsmith_config(
|
||||
self,
|
||||
*,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
run_name: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Build the config for LangSmith tracing."""
|
||||
if not self.is_langsmith_enabled:
|
||||
return None
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"tags": tags or [],
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
if run_name:
|
||||
config["run_name"] = run_name
|
||||
|
||||
if self.langsmith_project:
|
||||
config["metadata"].setdefault("langsmith_project", self.langsmith_project)
|
||||
|
||||
return config
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _apply_external_env_overrides(self) -> None:
|
||||
"""Allow provider/native env vars to override defaults."""
|
||||
for attr, env_names in self._PROVIDER_ENV_MAP.items():
|
||||
if getattr(self, attr):
|
||||
continue
|
||||
for env_name in env_names:
|
||||
value = os.getenv(env_name)
|
||||
if value:
|
||||
setattr(self, attr, value)
|
||||
break
|
||||
|
||||
def _provider_value(self, attr: str) -> str | None:
|
||||
"""Return the configured value for a provider attr (env fallback)."""
|
||||
value = getattr(self, attr, None)
|
||||
if value:
|
||||
return value
|
||||
for env_name in self._PROVIDER_ENV_MAP.get(attr, ()): # type: ignore[arg-type]
|
||||
env_value = os.getenv(env_name)
|
||||
if env_value:
|
||||
return env_value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _derive_test_database_url(url: str) -> str:
|
||||
if url.startswith("sqlite") and url.endswith(".db"):
|
||||
return url[:-3] + "_test.db"
|
||||
return url
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get the global settings instance.
|
||||
|
||||
Returns:
|
||||
Settings instance (singleton)
|
||||
"""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
"get_settings",
|
||||
]
|
||||
"""Get the application settings."""
|
||||
return Settings.get_settings()
|
||||
|
||||
Reference in New Issue
Block a user