fix: thread safety AgentWithMemory and improve ToolAgent JSON parsing and mypy issues

This commit is contained in:
2025-10-10 19:38:52 +05:30
parent bc8cec1d89
commit 3aa3d62389
9 changed files with 444 additions and 758 deletions
+4 -5
View File
@@ -36,7 +36,7 @@ pip install cleveragents
```bash
git clone https://git.cleverthis.com/cleveragents/cleveragents-core
cd cleveragents
cd cleveragents-core
pip install -e .
```
@@ -272,7 +272,7 @@ cleveragents interactive -c examples/collaboration_reactive.yaml
### Running Tests
```bash
# Run all tests
# Run all BDD tests
python -m behave tests/features
# Run with coverage
@@ -294,12 +294,11 @@ tox -e py312-cover
tox -e py312-nocov
# Individual test scripts:
bash tests/scripts/test_single_turn_writer.sh
bash tests/scripts/test_multi_agent_paper_writer_langgraph.sh
bash tests/scripts/test_legal_contract_analyzer_langgraph.sh
bash tests/scripts/test_paper_writer_section_by_section.sh
bash tests/scripts/test_multi_agent_interactive.sh
bash tests/scripts/test_legal_contract_analyzer.sh
```
### Contributing
@@ -1,384 +0,0 @@
# Legal Contract Metadata Extractor - True Multi-Agent Analysis System
# Multiple specialized agents actually working together through stream pipeline
#
# AGENTS:
# - Coordinator: Routes workflow and loads documents
# - Text Preprocessor: Cleans and structures contract text
# - Contract Analyzer: Extracts detailed metadata
# - Risk Assessor: Identifies risks and issues
# - JSON Formatter: Creates structured output
# - File Manager: Saves analysis results
#
# USAGE:
# cleveragents interactive -c examples/legal_contract_metadata_extractor.yaml --unsafe
#
# HOW IT WORKS:
# - Each agent actually executes (not simulation)
# - Messages flow through stream pipeline: coordinator → preprocessor → analyzer → risk_assessor → formatter
# - All agents share conversation history (memory enabled)
# - Natural progression through analysis stages
agents:
# File operations agents
file_reader:
type: tool
config:
tools: ["file_read"]
safe_mode: true
file_manager:
type: tool
config:
tools: ["file_write"]
safe_mode: false
# Coordinator agent - manages workflow and loads documents
coordinator:
type: llm
config:
provider: openai
model: gpt-4o
temperature: 0.3
memory_enabled: true
max_history: 20
max_tokens: 2000
system_prompt: |
You are the COORDINATOR agent in a multi-agent legal contract analysis system.
YOUR ROLE: Load contract documents and initiate the analysis workflow.
RESPONSIBILITIES:
- Detect when user provides a file path
- Load the contract document using file_read tool
- Pass the loaded content to the TEXT_PREPROCESSOR agent
FILE PATH DETECTION:
- Look for file paths in user input (e.g., "sample_contract.txt", "contract.pdf")
- Extract the file name from phrases like "analyze sample_contract.txt"
- If input contains file extension (.txt, .pdf, .docx) or mentions "contract"
FILE LOADING:
When file path detected, use this exact format:
[TOOL_EXECUTE:file_read]
{"file": "extracted_file_path"}
[/TOOL_EXECUTE]
OUTPUT FORMAT:
"=== COORDINATOR AGENT ===>
Loading contract document from [filename]...
[Tool execution will happen automatically]
Contract loaded. Passing to TEXT_PREPROCESSOR for cleaning and structuring."
Always start responses with: [COORDINATOR SPEAKING]
# Text preprocessing agent - cleans and structures contract text
text_preprocessor:
type: llm
config:
provider: openai
model: gpt-3.5-turbo
temperature: 0.1
memory_enabled: true
max_history: 20
system_prompt: |
You are the TEXT_PREPROCESSOR agent in a legal contract analysis system.
YOUR ROLE: Clean and structure raw contract text for analysis.
RESPONSIBILITIES:
- Fix OCR errors and formatting issues
- Identify document sections (headers, clauses, signatures)
- Normalize dates to standard format (YYYY-MM-DD)
- Standardize monetary amounts ($X,XXX.XX USD)
- Extract proper nouns (companies, people, locations)
- Label key sections with clear markers
SECTION MARKERS:
[PARTIES] ... [/PARTIES]
[DATES] ... [/DATES]
[FINANCIAL_TERMS] ... [/FINANCIAL_TERMS]
[OBLIGATIONS] ... [/OBLIGATIONS]
[LEGAL_TERMS] ... [/LEGAL_TERMS]
OUTPUT FORMAT:
"=== TEXT_PREPROCESSOR AGENT ===>
Cleaning and structuring the contract text...
[PARTIES]
- TechCorp Solutions Inc. (Provider)
- Global Enterprises LLC (Client)
[/PARTIES]
[DATES]
- Effective Date: 2024-01-15
- Term: 24 months
- Expiration: 2026-01-15
[/DATES]
[FINANCIAL_TERMS]
- Monthly Fee: $5,000.00 USD
- Payment Due: 30 days from invoice
- Late Penalty: 1.5% per month
[/FINANCIAL_TERMS]
[OBLIGATIONS]
- Virtual server hosting
- Data storage and backup
- Network infrastructure management
- 24/7 technical support
- 99.9% uptime guarantee
[/OBLIGATIONS]
[LEGAL_TERMS]
- Governing Law: California
- Termination: 90 days notice
- Liability Limit: 12 months payments
- Confidentiality: Required
[/LEGAL_TERMS]
Text preprocessing complete. Passing to CONTRACT_ANALYZER."
Always start responses with: [TEXT_PREPROCESSOR SPEAKING]
# Contract analysis agent - extracts detailed metadata
contract_analyzer:
type: llm
config:
provider: openai
model: gpt-4
temperature: 0.2
memory_enabled: true
max_history: 20
max_tokens: 2000
system_prompt: |
You are the CONTRACT_ANALYZER agent in a legal contract analysis system.
YOUR ROLE: Extract comprehensive metadata from preprocessed contract text.
EXTRACTION CATEGORIES:
- PARTIES: Company names, roles, addresses, representatives
- DATES: Signing, effective, expiration, payment, milestones
- FINANCIAL TERMS: Values, schedules, penalties, currency
- OBLIGATIONS: Deliverables, requirements, standards
- LEGAL TERMS: Governing law, termination, liability, IP, confidentiality
OUTPUT FORMAT:
"=== CONTRACT_ANALYZER AGENT ===>
Extracting comprehensive metadata from the contract...
PARTIES INFORMATION:
- Provider: TechCorp Solutions Inc.
* Type: Delaware Corporation
* Address: 123 Tech Street, San Francisco, CA 94105
* Representative: John Smith (CTO)
* Confidence: 0.95
- Client: Global Enterprises LLC
* Type: California LLC
* Address: 456 Business Ave, Los Angeles, CA 90001
* Representative: Sarah Johnson (CEO)
* Confidence: 0.95
KEY DATES:
- Effective Date: January 15, 2024 (Confidence: 1.0)
- Contract Term: 24 months (Confidence: 1.0)
- Termination Notice: 90 days (Confidence: 1.0)
- Payment Due: 30 days from invoice (Confidence: 1.0)
FINANCIAL TERMS:
- Monthly Fee: $5,000.00 USD (Confidence: 1.0)
- Late Payment Penalty: 1.5% per month (Confidence: 1.0)
- Currency: USD (Confidence: 1.0)
OBLIGATIONS:
Provider must deliver:
- Virtual server hosting (Confidence: 1.0)
- Data storage and backup (Confidence: 1.0)
- Network infrastructure management (Confidence: 1.0)
- 24/7 technical support (Confidence: 1.0)
- 99.9% uptime guarantee (Confidence: 1.0)
LEGAL TERMS:
- Governing Law: State of California (Confidence: 1.0)
- Termination Rights: 90 days notice or immediate for breach (Confidence: 1.0)
- Liability Limit: 12 months of payments (Confidence: 1.0)
- Confidentiality: Required for proprietary information (Confidence: 1.0)
Metadata extraction complete. Passing to RISK_ASSESSOR."
Always start responses with: [CONTRACT_ANALYZER SPEAKING]
# Risk assessment agent - identifies issues and risks
risk_assessor:
type: llm
config:
provider: openai
model: gpt-4
temperature: 0.3
memory_enabled: true
max_history: 20
max_tokens: 2000
system_prompt: |
You are the RISK_ASSESSOR agent in a legal contract analysis system.
YOUR ROLE: Identify risks, issues, and potential problems in contracts.
RISK ASSESSMENT AREAS:
- HIGH-RISK TERMS: Unusual or unfavorable provisions
- MISSING CLAUSES: Absent standard protections
- AMBIGUOUS LANGUAGE: Unclear or vague terms
- COMPLIANCE CONCERNS: Regulatory or industry issues
OUTPUT FORMAT:
"=== RISK_ASSESSOR AGENT ===>
Conducting comprehensive risk assessment...
HIGH-RISK TERMS IDENTIFIED:
- Limited liability cap may be insufficient for data breach scenarios
- No specific data privacy or GDPR compliance mentioned
- Confidence: 0.85
MISSING STANDARD CLAUSES:
- Force Majeure provisions (Confidence: 0.95)
- Dispute Resolution mechanism (Confidence: 0.90)
- Data protection and privacy clauses (Confidence: 0.95)
- Intellectual Property ownership clarification (Confidence: 0.85)
- Service Level Agreement penalties for downtime (Confidence: 0.80)
AMBIGUOUS LANGUAGE:
- 'Proprietary information' not clearly defined (Confidence: 0.85)
- Technical specifications for services not detailed (Confidence: 0.80)
COMPLIANCE CONCERNS:
- No data breach notification procedures
- Missing data residency requirements
- No mention of industry-specific compliance (HIPAA, SOC2, etc.)
OVERALL RISK ASSESSMENT:
- Risk Score: 0.65 (Medium-High)
- Risk Level: Medium-High
- Recommendation: Add data protection clauses and dispute resolution
Risk assessment complete. Passing to JSON_FORMATTER."
Always start responses with: [RISK_ASSESSOR SPEAKING]
# JSON formatting agent - structures analysis for programmatic use
metadata_formatter:
type: llm
config:
provider: openai
model: gpt-3.5-turbo
temperature: 0.0
memory_enabled: true
max_history: 15
max_tokens: 3000
system_prompt: |
You are the JSON_FORMATTER agent in a legal contract analysis system.
YOUR ROLE: Convert contract analysis into structured JSON format.
TASK:
- Review the conversation history
- Extract all analysis from TEXT_PREPROCESSOR, CONTRACT_ANALYZER, and RISK_ASSESSOR
- Format into comprehensive JSON structure
JSON OUTPUT STRUCTURE:
{
"document_info": {
"analysis_date": "YYYY-MM-DD",
"document_type": "service_agreement",
"total_confidence": 0.91,
"analysis_version": "1.0"
},
"parties": [...],
"dates": {...},
"financial_terms": {...},
"obligations": {...},
"legal_terms": {...},
"risk_assessment": {...},
"extraction_summary": {...}
}
OUTPUT FORMAT:
"=== JSON_FORMATTER AGENT ===>
Converting analysis to structured JSON format...
```json
{
[COMPLETE JSON WITH ALL EXTRACTED DATA]
}
```
Analysis complete. To save results, ask user for filename."
Always start responses with: [JSON_FORMATTER SPEAKING]
routes:
# Stage 1: Coordinator loads document
coordinator_stream:
type: stream
stream_type: cold
operators:
- type: map
params:
agent: coordinator
# Stage 2: Text Preprocessor cleans text
preprocessor_stream:
type: stream
stream_type: cold
operators:
- type: map
params:
agent: text_preprocessor
# Stage 3: Contract Analyzer extracts metadata
analyzer_stream:
type: stream
stream_type: cold
operators:
- type: map
params:
agent: contract_analyzer
# Stage 4: Risk Assessor identifies issues
risk_stream:
type: stream
stream_type: cold
operators:
- type: map
params:
agent: risk_assessor
# Stage 5: JSON Formatter creates structured output
formatter_stream:
type: stream
stream_type: cold
operators:
- type: map
params:
agent: metadata_formatter
publications:
- __output__
merges:
- sources: [__input__]
target: coordinator_stream
- sources: [coordinator_stream]
target: preprocessor_stream
- sources: [preprocessor_stream]
target: analyzer_stream
- sources: [analyzer_stream]
target: risk_stream
- sources: [risk_stream]
target: formatter_stream
context:
global:
app_name: "Legal Contract Metadata Extractor"
version: "2.0-true-multi-agent"
_unsafe_mode: true
log_level: "INFO"
@@ -44,56 +44,56 @@ agents:
max_history: 30
system_prompt: |
You are the COORDINATOR agent in a multi-agent paper writing system.
YOUR ROLE: Analyze user input and decide which specialist should handle it.
AVAILABLE SPECIALISTS:
- RESEARCHER: For gathering requirements, understanding research questions
- WRITER: For writing papers (whole or sections)
- REVIEWER: For reviewing and improving written content
- FILE_MANAGER: For file operations (save, read, append)
ROUTING DECISIONS:
Based on conversation context, output ONE of these routing tags:
[ROUTE:RESEARCHER] - If user wants to start, discuss topic, or define requirements
[ROUTE:WRITER] - If requirements are clear and user wants content written
[ROUTE:REVIEWER] - If paper/content exists and needs review/improvement
[ROUTE:FILE_MANAGER] - If user wants to save/read/append content to a file
[ROUTE:END] - If workflow is complete or user says goodbye
FILE OPERATION INSTRUCTIONS:
When user wants file operations, extract key information and route to FILE_MANAGER:
For SAVE/WRITE (new file or overwrite):
"User wants to save content to 'paper.txt' (new/overwrite).
[ROUTE:FILE_MANAGER]
OPERATION: write
FILENAME: paper.txt"
For APPEND (add to end of file):
"User wants to append new section to 'paper.txt'.
[ROUTE:FILE_MANAGER]
OPERATION: append
FILENAME: paper.txt"
For READ (read existing file):
"User wants to read 'paper.txt'.
[ROUTE:FILE_MANAGER]
OPERATION: read
FILENAME: paper.txt"
SECTION-BY-SECTION WORKFLOW:
- User can write one section at a time (abstract, introduction, methodology, etc.)
- Each section can be saved individually or appended to existing file
- Support iterative refinement of individual sections
OUTPUT FORMAT:
Provide brief context, then routing decision:
"User wants to write the abstract for a quantum computing paper. Routing to writer for content creation.
[ROUTE:WRITER]"
Always include the [ROUTE:X] tag in your response!
# Researcher agent - gathers requirements
@@ -107,9 +107,9 @@ agents:
max_history: 30
system_prompt: |
You are the RESEARCHER agent in a multi-agent paper writing system.
YOUR ROLE: Gather requirements and define the research scope for papers.
RESPONSIBILITIES:
- Ask clarifying questions about the research topic
- Understand the research question or thesis
@@ -117,22 +117,22 @@ agents:
- Define paper scope and key points
- Gather any specific requirements
- Support both full papers and individual sections
CONVERSATION CONTEXT:
- You can see the entire conversation history
- Build on what users have already said
- Don't repeat questions if information was already provided
COMPLETION SIGNAL:
When you have enough information, end your response with:
"[REQUIREMENTS_COMPLETE]"
This signals the coordinator to route to the WRITER.
OUTPUT FORMAT:
"[RESEARCHER SPEAKING]
[Your questions and analysis]
[REQUIREMENTS_COMPLETE] (only when done)"
# Writer agent - writes papers or sections
@@ -147,65 +147,65 @@ agents:
max_history: 30
system_prompt: |
You are the WRITER agent in a multi-agent paper writing system.
YOUR ROLE: Write complete papers OR individual sections based on user request.
ACCESSING REQUIREMENTS:
- Review the COMPLETE conversation history
- Look for "[RESEARCHER SPEAKING]" for requirements
- Look for coordinator instructions about what to write
- Check if user wants full paper or specific section
FULL PAPER STRUCTURE:
# [Descriptive Title Based on Topic]
## Abstract
[150-250 words]
## Introduction
[Background and research question]
## Methodology
[Research approach]
## Results
[Key findings]
## Discussion
[Analysis]
## Conclusion
[Summary and future work]
## References
[3-5 relevant references]
SECTION-BY-SECTION MODE:
If user asks for specific section (e.g., "write the abstract", "write introduction"):
- Write ONLY that section with appropriate heading
- Make it complete and self-contained
- Follow academic writing standards
- Include section heading (e.g., "## Abstract")
CONTINUING SECTIONS:
If user says "now write the next section" or "write methodology":
- Review conversation history to see what's already written
- Write the requested section that follows logically
- Maintain consistent style and tone
WRITING STYLE:
- Academic and professional
- Appropriate for target audience
- Clear and well-structured
COMPLETION SIGNAL:
End your response with: "[PAPER_COMPLETE]" for full paper or "[SECTION_COMPLETE]" for individual section
End your response with: "[PAPER_COMPLETE]" for full paper and ignore the ending if writing a section
OUTPUT FORMAT:
"[WRITER SPEAKING]
[Content here]
[PAPER_COMPLETE] or [SECTION_COMPLETE]"
[PAPER_COMPLETE] and ignore the ending if writing a section
# Reviewer agent - reviews and improves papers
reviewer:
@@ -218,20 +218,20 @@ agents:
max_history: 30
system_prompt: |
You are the REVIEWER agent in a multi-agent paper writing system.
YOUR ROLE: Review papers or sections and provide constructive feedback.
ACCESSING CONTENT TO REVIEW:
- Review the COMPLETE conversation history
- Look for "[WRITER SPEAKING]" for the content
- Look for "[FILE_CONTENT_START]" if content was read from file
- Extract the full content (sections or complete paper)
- Then provide your detailed review
IMPORTANT: The content EXISTS in the conversation history!
Search for "[WRITER SPEAKING]" or "[FILE_CONTENT_START]" and extract everything after it.
Do NOT say you can't find the content - it's in the conversation!
REVIEW CRITERIA:
- Structure and organization
- Clarity and coherence
@@ -239,20 +239,20 @@ agents:
- Appropriate level for target audience
- Completeness
- Consistency with previous sections (if reviewing incrementally)
OUTPUT:
- Highlight strengths
- Identify areas for improvement
- Suggest specific changes
- Can provide revised sections if needed
COMPLETION SIGNAL:
End your response with: "[REVIEW_COMPLETE]"
OUTPUT FORMAT:
"[REVIEWER SPEAKING]
[Your review and feedback]
[REVIEW_COMPLETE]"
# File manager agent - intelligently handles file operations
@@ -266,39 +266,39 @@ agents:
max_history: 30
system_prompt: |
You are the FILE_MANAGER agent in a multi-agent paper writing system.
YOUR ROLE: Execute file operations based on coordinator instructions.
OPERATIONS:
1. WRITE - Save new content or overwrite existing file
2. APPEND - Add content to end of existing file
3. READ - Read existing file content
INSTRUCTIONS:
1. Check coordinator message for "OPERATION:" and "FILENAME:"
2. Search conversation history for content to save
- Look for "[WRITER SPEAKING]" for newly written content
- Look for most recent content in conversation
3. Format appropriate JSON command based on operation
OUTPUT FORMATS:
For WRITE operation:
{"tool": "file_write", "args": {"file": "filename.txt", "content": "CONTENT_HERE", "mode": "w"}}
For APPEND operation:
{"tool": "file_write", "args": {"file": "filename.txt", "content": "CONTENT_HERE", "mode": "a"}}
For READ operation:
{"tool": "file_read", "args": {"file": "filename.txt"}}
CRITICAL RULES:
- Output ONLY the JSON command, no other text before or after
- Include the COMPLETE content from writer
- Use the exact filename from FILENAME: field
- Use correct mode: "w" for write/overwrite, "a" for append
- For READ, just read the file (no content needed)
# Tool executor for file reading
file_reader_tool:
type: tool
@@ -319,111 +319,111 @@ routes:
type: graph
entry_point: start
checkpointing: true
nodes:
# Coordinator decides routing
coordinate:
type: agent
agent: coordinator
# Researcher gathers requirements
research:
type: agent
agent: researcher
# Writer creates content
write:
type: agent
agent: writer
# Reviewer provides feedback
review:
type: agent
agent: reviewer
# File manager formats file commands
manage_file:
type: agent
agent: file_manager
# Tool executors for file operations
read_file:
type: agent
agent: file_reader_tool
write_file:
type: agent
agent: file_writer_tool
edges:
# Always start with coordinator
- source: start
target: coordinate
# Coordinator routes to researcher
- source: coordinate
target: research
condition:
type: content_contains
text: "[ROUTE:RESEARCHER]"
# Coordinator routes to writer
- source: coordinate
target: write
condition:
type: content_contains
text: "[ROUTE:WRITER]"
# Coordinator routes to reviewer
- source: coordinate
target: review
condition:
type: content_contains
text: "[ROUTE:REVIEWER]"
# Coordinator routes to file manager
- source: coordinate
target: manage_file
condition:
type: content_contains
text: "[ROUTE:FILE_MANAGER]"
# Coordinator ends workflow
- source: coordinate
target: end
condition:
type: content_contains
text: "[ROUTE:END]"
# After research, go back to coordinator for next decision
- source: research
target: coordinate
# After writing, go back to coordinator
- source: write
target: coordinate
# After review, go back to coordinator
- source: review
target: coordinate
# File manager routes to read or write based on JSON command
- source: manage_file
target: read_file
condition:
type: content_contains
text: '"tool": "file_read"'
- source: manage_file
target: write_file
condition:
type: content_contains
text: '"tool": "file_write"'
# After file operations, go back to coordinator
- source: read_file
target: coordinate
- source: write_file
target: coordinate
+69
View File
@@ -0,0 +1,69 @@
SERVICE AGREEMENT
This Service Agreement ("Agreement") is entered into as of January 15, 2024 (the "Effective Date"), by and between:
TechCorp Solutions Inc., a Delaware corporation with its principal place of business at 123 Tech Street, San Francisco, CA 94105 ("Provider"),
and
Global Enterprises LLC, a California limited liability company with its principal place of business at 456 Business Ave, Los Angeles, CA 90001 ("Client").
RECITALS
WHEREAS, Provider is engaged in the business of providing cloud computing and data analytics services;
WHEREAS, Client desires to engage Provider to provide certain cloud infrastructure and data processing services;
NOW, THEREFORE, in consideration of the mutual promises and covenants contained herein, the parties agree as follows:
1. SERVICES
1.1 Provider shall provide Client with access to its cloud computing platform, including but not limited to:
- Virtual server hosting
- Data storage and backup services
- Network infrastructure management
- 24/7 technical support
1.2 Service Level Agreement: Provider guarantees 99.9% uptime for all hosted services.
2. PAYMENT TERMS
2.1 Client shall pay Provider a monthly fee of $5,000.00 USD for the services described herein.
2.2 Payment shall be due within 30 days of invoice date.
2.3 Late payments shall incur a penalty of 1.5% per month.
3. TERM AND TERMINATION
3.1 This Agreement shall commence on the Effective Date and continue for a period of 24 months.
3.2 Either party may terminate this Agreement upon 90 days written notice.
3.3 Provider may terminate immediately for non-payment or material breach.
4. CONFIDENTIALITY
4.1 Both parties agree to maintain the confidentiality of proprietary information shared during the term of this Agreement.
5. GOVERNING LAW
5.1 This Agreement shall be governed by the laws of the State of California.
6. LIMITATION OF LIABILITY
6.1 Provider's total liability shall not exceed the amount paid by Client in the 12 months preceding the claim.
IN WITNESS WHEREOF, the parties have executed this Agreement as of the date first above written.
TechCorp Solutions Inc.
By: ___________________________
Name: John Smith
Title: Chief Technology Officer
Global Enterprises LLC
By: ___________________________
Name: Sarah Johnson
Title: Chief Executive Officer
+42 -32
View File
@@ -7,18 +7,20 @@ provide async processing capabilities for the reactive architecture.
import asyncio
import copy
import threading
from abc import ABC
from abc import abstractmethod
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
import rx
from rx import operators as ops
from rx.core import Observable
from rx.core import Observer
from rx.subject import Subject
from rx.core import Observable # type: ignore[attr-defined]
from rx.core import Observer # type: ignore[attr-defined]
from rx.subject import Subject # type: ignore[attr-defined]
from cleveragents.core.exceptions import AgentCreationError
from cleveragents.core.exceptions import ExecutionError
@@ -65,16 +67,16 @@ class Agent(ABC):
# Set up processing pipeline
self._setup_processing_pipeline()
def _setup_processing_pipeline(self):
def _setup_processing_pipeline(self) -> None:
"""Set up the reactive processing pipeline."""
self.input_stream.pipe(
ops.map(self._process_wrapper),
ops.flat_map(rx.from_future),
ops.map(self._process_wrapper), # type: ignore[arg-type]
ops.flat_map(rx.from_future), # type: ignore[arg-type]
).subscribe(
on_next=self.output_stream.on_next, on_error=self.output_stream.on_error
)
async def _process_wrapper(self, message_data):
async def _process_wrapper(self, message_data: tuple[str, Dict[str, Any]]) -> str:
"""Wrapper for async processing."""
try:
if isinstance(message_data, tuple):
@@ -145,11 +147,11 @@ class Agent(ABC):
return metadata
def send_message(self, message: str, context: Optional[Dict[str, Any]] = None):
def send_message(self, message: str, context: Optional[Dict[str, Any]] = None) -> None:
"""Send a message to the agent's input stream."""
self.input_stream.on_next((message, context or {}))
def subscribe_to_output(self, observer: Observer):
def subscribe_to_output(self, observer: Observer) -> None:
"""Subscribe to the agent's output stream."""
self.output_stream.subscribe(observer)
@@ -157,7 +159,7 @@ class Agent(ABC):
"""Create an observable from the agent's processing."""
return self.output_stream.pipe()
def dispose(self):
def dispose(self) -> None:
"""Clean up the agent's resources."""
if hasattr(self.input_stream, "dispose"):
self.input_stream.dispose()
@@ -176,6 +178,9 @@ class AgentWithMemory(Agent):
memory (Dict[str, Any]): The agent's memory/state.
"""
# Class-level threading lock to protect asyncio.Lock initialization
_memory_lock_init_lock = threading.Lock()
def __init__(
self, name: str, config: Dict[str, Any], template_renderer: TemplateRenderer
):
@@ -194,17 +199,22 @@ class AgentWithMemory(Agent):
@property
def _memory_lock(self) -> asyncio.Lock:
"""Lazily create and return the memory lock, ensuring an event loop exists."""
# Fast path: check without lock first
if self._memory_lock_instance is None:
try:
loop = asyncio.get_event_loop()
except RuntimeError:
# No event loop in current thread, create one
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._memory_lock_instance = asyncio.Lock()
# Thread-safe initialization using double-checked locking
with self._memory_lock_init_lock:
# Double-check: another thread might have initialized it
if self._memory_lock_instance is None:
try:
loop = asyncio.get_event_loop()
except RuntimeError:
# No event loop in current thread, create one
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._memory_lock_instance = asyncio.Lock()
return self._memory_lock_instance
async def _process_wrapper(self, message_data):
async def _process_wrapper(self, message_data: tuple[str, Dict[str, Any]]) -> str:
"""Wrapper that manages memory access."""
async with self._memory_lock:
return await super()._process_wrapper(message_data)
@@ -266,7 +276,7 @@ class StreamableAgent(Agent):
allowing it to be used as an operator in RxPy pipelines.
"""
def as_operator(self):
def as_operator(self) -> Callable[[Observable], Observable]:
"""
Return an RxPy operator that processes messages through this agent.
@@ -275,14 +285,14 @@ class StreamableAgent(Agent):
"""
def operator(source: Observable) -> Observable:
def subscribe(observer, scheduler=None):
def on_next(value):
def subscribe(observer: Observer, scheduler: Any = None) -> Any:
def on_next(value: Any) -> None:
self.send_message(str(value))
def on_error(error):
def on_error(error: Exception) -> None:
observer.on_error(error)
def on_completed():
def on_completed() -> None:
observer.on_completed()
# Subscribe to agent's output
@@ -296,11 +306,11 @@ class StreamableAgent(Agent):
scheduler=scheduler,
)
return rx.create(subscribe) # type: ignore
return rx.create(subscribe) # type: ignore[no-untyped-call]
return operator
def map_operator(self):
def map_operator(self) -> Any:
"""
Return an RxPy map operator that processes values through this agent.
@@ -308,10 +318,10 @@ class StreamableAgent(Agent):
An RxPy map operator.
"""
async def process_value(value):
async def process_value(value: Any) -> Any:
result_future: asyncio.Future[Any] = asyncio.Future()
def on_result(result):
def on_result(result: Any) -> None:
if not result_future.done():
result_future.set_result(result)
@@ -321,9 +331,9 @@ class StreamableAgent(Agent):
return await result_future
return ops.map(process_value)
return ops.map(process_value) # type: ignore[arg-type]
def filter_operator(self, condition_func):
def filter_operator(self, condition_func: Callable[[Any], bool]) -> Any:
"""
Return an RxPy filter operator based on agent processing.
@@ -334,10 +344,10 @@ class StreamableAgent(Agent):
An RxPy filter operator.
"""
async def filter_with_agent(value):
async def filter_with_agent(value: Any) -> bool:
result_future: asyncio.Future[bool] = asyncio.Future()
def on_result(result):
def on_result(result: Any) -> None:
if not result_future.done():
result_future.set_result(condition_func(result))
@@ -347,4 +357,4 @@ class StreamableAgent(Agent):
return await result_future
return ops.filter(filter_with_agent)
return ops.filter(filter_with_agent) # type: ignore[arg-type]
+227 -68
View File
@@ -8,11 +8,12 @@ capabilities within the RxPy-based reactive architecture.
import asyncio
import json
import logging
import re
import subprocess
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Optional, Union, Literal
import aiohttp
@@ -82,6 +83,58 @@ class ToolAgent(Agent):
else:
raise AgentCreationError(f"Invalid tool configuration: {tool}")
def _extract_json_from_message(self, message: str) -> Optional[Dict[str, Any]]:
"""
Extract JSON from a message that might contain markdown code blocks or extra text.
Args:
message: The message that might contain JSON.
Returns:
Parsed JSON dict if found, None otherwise.
Raises:
json.JSONDecodeError: If message appears to be JSON but is malformed.
"""
message_stripped = message.strip()
# If message looks like JSON (starts with { and ends with }), it must be valid JSON
if message_stripped.startswith("{") and message_stripped.endswith("}"):
# This looks like JSON, so if it fails to parse, raise an error
parsed = json.loads(message_stripped)
if isinstance(parsed, dict): # ✅ Add type check
return parsed
return None
# Try to extract JSON from markdown code blocks
# Pattern: ```json ... ``` or ``` ... ```
code_block_pattern = r'```(?:json)?\s*(\{.*?\})\s*```'
match = re.search(code_block_pattern, message, re.DOTALL)
if match:
try:
parsed = json.loads(match.group(1))
if isinstance(parsed, dict): # ✅ Add type check
return parsed
return None # Not a dict
except json.JSONDecodeError:
# Code block had invalid JSON, raise error
raise
# Try to find any JSON object in the message
json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
match = re.search(json_pattern, message, re.DOTALL)
if match:
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, dict): # ✅ Add type check
return parsed
return None # Not a dict
except json.JSONDecodeError:
# Found JSON-like content but it's invalid, ignore and fall through
pass
return None
async def process_message(
self, message: str, context: Optional[Dict[str, Any]] = None
) -> str:
@@ -99,21 +152,26 @@ class ToolAgent(Agent):
ExecutionError: If tool execution fails.
"""
try:
# Parse tool execution request
if message.startswith("{") and message.endswith("}"):
# JSON format tool request
tool_request = json.loads(message)
# Try to extract JSON tool request from message
tool_request = self._extract_json_from_message(message)
if tool_request:
tool_name = tool_request.get("tool")
tool_args = tool_request.get("args", {})
else:
# Simple format: "tool_name arg1 arg2"
parts = message.strip().split()
if not parts:
raise ExecutionError("Empty tool request")
tool_name = parts[0]
tool_args = {"args": parts[1:]} if len(parts) > 1 else {}
if tool_name:
# Execute the tool
result = await self._execute_tool(tool_name, tool_args, context)
return str(result)
# Fall back to simple format: "tool_name arg1 arg2"
message_stripped = message.strip()
parts = message_stripped.split()
if not parts:
raise ExecutionError("Empty tool request")
tool_name = parts[0]
tool_args = {"args": parts[1:]} if len(parts) > 1 else {}
# Execute the tool
result = await self._execute_tool(tool_name, tool_args, context)
@@ -309,94 +367,195 @@ class ToolAgent(Agent):
except Exception as e:
raise ExecutionError(f"File read failed: {e}") from e
def _clean_control_markers(self, content: str) -> str:
"""
Remove control markers from content before writing to files.
This ensures that workflow control signals like [SECTION_COMPLETE],
[PAPER_COMPLETE], [WRITER SPEAKING], etc., don't end up in the output files.
Args:
content: The raw content with potential control markers
Returns:
Cleaned content without control markers
"""
import re
# List of control markers to remove
control_markers = [
r'\[SECTION_COMPLETE\]',
r'\[PAPER_COMPLETE\]',
r'\[WRITER SPEAKING\]',
r'\[RESEARCHER SPEAKING\]',
r'\[REVIEWER SPEAKING\]',
r'\[REQUIREMENTS_COMPLETE\]',
r'\[REVIEW_COMPLETE\]',
r'\[ROUTE:\w+\]',
r'\[FILE_READ_SUCCESS\]',
r'\[FILE_CONTENT_START\]',
r'\[FILE_CONTENT_END\]',
]
# Remove each marker
cleaned = content
for marker in control_markers:
cleaned = re.sub(marker, '', cleaned)
# Clean up excessive whitespace that might result from marker removal
# But preserve intentional paragraph breaks
cleaned = re.sub(r'\n\n\n+', '\n\n', cleaned)
cleaned = cleaned.strip()
return cleaned
def _validate_file_write_args(
self, filepath: str, content: str
) -> None:
"""Validate file write arguments."""
if not filepath or not content:
logger.error("File write requires filepath and content")
raise ExecutionError("File write tool requires file path and content")
def _validate_file_path_safety(
self, filepath: str, unsafe_mode: bool
) -> None:
"""Validate file path for safety in safe mode."""
if not self.safe_mode:
return
# Always block directory traversal attempts
if ".." in filepath:
logger.error("Directory traversal blocked for %s", filepath)
raise ExecutionError("Unsafe file path blocked in safe mode")
# Block absolute paths unless in unsafe mode
if filepath.startswith("/") and not unsafe_mode:
logger.error("Absolute path blocked for %s", filepath)
raise ExecutionError("Unsafe file path blocked in safe mode")
def _prepare_append_content(
self, filepath: str, cleaned_content: str
) -> str:
"""Prepare content for append mode with proper spacing for sections."""
# Only add spacing if content looks like a document section (starts with #)
# This prevents breaking simple append operations
is_section = cleaned_content.lstrip().startswith('#')
if not is_section:
# Simple append without spacing
return cleaned_content
try:
with open(filepath, "r", encoding="utf-8") as f:
existing_content = f.read()
# Add spacing for section-based content
if existing_content and not existing_content.endswith('\n\n'):
if existing_content.endswith('\n'):
prefix = '\n' # Has one newline, add one more
else:
prefix = '\n\n' # No newline at all, add two
else:
prefix = ''
except FileNotFoundError:
prefix = '' # File doesn't exist yet
return prefix + cleaned_content
def _handle_insert_position(
self, filepath: str, cleaned_content: str, position: Union[None, int, Literal["start", "end"]]
) -> tuple[list[str], int]:
"""Handle insert mode positioning logic."""
try:
with open(filepath, "r", encoding="utf-8") as f:
existing_lines = f.readlines()
except FileNotFoundError:
existing_lines = []
# Ensure content ends with newline
formatted_content = (
cleaned_content if cleaned_content.endswith('\n')
else cleaned_content + '\n'
)
# Determine insertion position
if position is None or position == "end":
existing_lines.append(formatted_content)
insert_location = len(existing_lines)
elif position == "start":
existing_lines.insert(0, formatted_content)
insert_location = 1
elif isinstance(position, int):
line_idx = max(0, min(position - 1, len(existing_lines)))
existing_lines.insert(line_idx, formatted_content)
insert_location = line_idx + 1
else:
raise ExecutionError(
f"Invalid position '{position}'. "
f"Use 'start', 'end', or line number."
)
return existing_lines, insert_location
async def _file_write_tool(
self, args: Dict[str, Any], context: Optional[Dict[str, Any]]
) -> str:
"""File writing tool with support for write, append, and insert modes."""
filepath = args.get("file", "")
content = args.get("content", "")
mode = args.get("mode", "w") # "w" (write/overwrite), "a" (append), "insert" (insert at position)
position = args.get("position", None) # For insert mode: line number or "start"/"end"
mode = args.get("mode", "w") # w=write, a=append, insert=insert at pos
position = args.get("position", None)
if not filepath or not content:
logger.error("File write requires filepath and content")
raise ExecutionError("File write tool requires file path and content")
# Validate inputs
self._validate_file_write_args(filepath, content)
# Check unsafe mode requirement first (can be bypassed by context)
# Clean control markers from content before writing
cleaned_content = self._clean_control_markers(content)
# Check unsafe mode requirement
unsafe_mode = context and context.get("_unsafe_mode", False)
if not unsafe_mode:
logger.error("File writing requires unsafe mode")
raise ExecutionError("File writing requires unsafe mode")
if self.safe_mode:
# Always block directory traversal attempts
if ".." in filepath:
logger.error("Directory traversal blocked for %s", filepath)
raise ExecutionError("Unsafe file path blocked in safe mode")
# Block absolute paths unless in unsafe mode
if filepath.startswith("/") and not unsafe_mode:
logger.error("Absolute path blocked for %s", filepath)
raise ExecutionError("Unsafe file path blocked in safe mode")
# Validate file path safety
self._validate_file_path_safety(filepath, unsafe_mode)
try:
if mode == "w":
# Standard write (overwrite)
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return f"Successfully wrote {len(content)} characters to {filepath}"
f.write(cleaned_content)
return f"Successfully wrote {len(cleaned_content)} characters to {filepath}"
elif mode == "a":
# Append mode
# Append mode with proper spacing
content_to_append = self._prepare_append_content(filepath, cleaned_content)
with open(filepath, "a", encoding="utf-8") as f:
f.write(content)
return f"Successfully appended {len(content)} characters to {filepath}"
f.write(content_to_append)
return f"Successfully appended {len(cleaned_content)} characters to {filepath}"
elif mode == "insert":
# Insert mode - read existing content, insert at position, write back
try:
with open(filepath, "r", encoding="utf-8") as f:
existing_lines = f.readlines()
except FileNotFoundError:
# If file doesn't exist, create it with the content
existing_lines = []
# Determine insertion position
if position is None or position == "end":
# Append at end
existing_lines.append(content if content.endswith('\n') else content + '\n')
insert_location = len(existing_lines)
elif position == "start":
# Insert at beginning
existing_lines.insert(0, content if content.endswith('\n') else content + '\n')
insert_location = 1
elif isinstance(position, int):
# Insert at specific line number (1-indexed)
line_idx = max(0, min(position - 1, len(existing_lines)))
formatted_content = (
content if content.endswith('\n') else content + '\n'
)
existing_lines.insert(line_idx, formatted_content)
insert_location = line_idx + 1
else:
raise ExecutionError(
f"Invalid position '{position}'. "
f"Use 'start', 'end', or line number."
)
# Write back
# Insert mode at specified position
existing_lines, insert_location = self._handle_insert_position(
filepath, cleaned_content, position
)
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(existing_lines)
return (
f"Successfully inserted {len(content)} characters "
f"Successfully inserted {len(cleaned_content)} characters "
f"at line {insert_location} in {filepath}"
)
else:
raise ExecutionError(f"Invalid mode '{mode}'. Use 'w' (write), 'a' (append), or 'insert'.")
raise ExecutionError(
f"Invalid mode '{mode}'. Use 'w' (write), 'a' (append), or 'insert'."
)
except Exception as e:
logger.error("File write failed for %s: %s", filepath, e)
raise ExecutionError(f"File write failed: {e}") from e
def get_capabilities(self) -> List[str]:
"""Get the capabilities of the tool agent."""
capabilities = ["tool-execution", "command-execution"]
+27 -27
View File
@@ -665,37 +665,24 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes
"""
Sanitize JSON string by escaping control characters that LLMs often forget to escape.
This handles cases where LLMs output literal newlines, tabs, etc. in JSON strings
instead of properly escaped sequences.
Args:
json_str: Raw JSON string that may contain unescaped control characters
Returns:
str: Sanitized JSON string with control characters properly escaped
... existing docstring ...
"""
def is_valid_json(s: str) -> bool:
"""Check if string is valid JSON."""
try:
json.loads(s)
return True
except json.JSONDecodeError:
return False
# Try to parse as-is first
try:
json.loads(json_str)
if is_valid_json(json_str):
return json_str # Already valid, no sanitization needed
except json.JSONDecodeError:
pass # Need to sanitize
# Strategy: Find all quoted string values and escape control characters within them
# We need to be careful to only escape content inside string values, not the JSON structure
# Strategy: Find all quoted string values and escape control characters
def escape_string_content(match: Any) -> str:
"""
Escape control characters in a matched string value.
Args:
match: Regex match object containing the string content
Returns:
str: Escaped string content with surrounding quotes
"""
# match.group(0) is the full match including quotes
# match.group(1) is the content inside the quotes
"""Escape control characters in a matched string value."""
content = match.group(1)
# Escape backslashes first (to avoid double-escaping)
@@ -716,6 +703,16 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes
sanitized = re.sub(pattern, escape_string_content, json_str, flags=re.DOTALL)
# ✅ NEW: Validate the sanitized result
if not is_valid_json(sanitized):
logger.warning(
"JSON sanitization produced invalid JSON. "
"Original length: %d, Sanitized length: %d. "
"The sanitization logic may need improvement.",
len(json_str),
len(sanitized)
)
return sanitized
def _execute_single_tool(self, tool_name: str, tool_params: Any) -> str:
@@ -732,7 +729,7 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes
try:
# Find the appropriate agent that can execute this tool
target_agent = None
for _, agent in self.agents.items():
for agent in self.agents.values():
if hasattr(agent, 'tools') and tool_name in agent.tools:
target_agent = agent
break
@@ -871,6 +868,9 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes
print(f"Executing graph '{graph_name}'...")
# Prepare metadata with unsafe mode and context
if not self.config:
raise CleverAgentsException("Configuration not loaded")
metadata: Dict[str, Any] = {"context": self.config.global_context}
metadata["_unsafe_mode"] = self.unsafe
+1 -1
View File
@@ -15,6 +15,7 @@ from typing import Optional
from typing import Union
from cleveragents.agents.base import Agent
from cleveragents.agents.tool import ToolAgent
from cleveragents.langgraph.state import GraphState
from cleveragents.langgraph.state import StateUpdateMode
@@ -149,7 +150,6 @@ class Node:
if state.messages:
# Check if this is a tool agent that should receive the last message
# (regardless of role) to process commands from other agents
from cleveragents.agents.tool import ToolAgent
if isinstance(agent, ToolAgent):
# Tool agents receive the last message (likely from another agent)
agent_input = state.messages[-1].get("content", "")
@@ -1,167 +0,0 @@
#!/bin/bash
# Comprehensive test script for Legal Contract Analyzer
# Tests: legal_contract_metadata_extractor.yaml with sample_contract.txt
# Change to project root
cd "$(dirname "$0")/../.."
# Check for API key
if [ -z "$OPENAI_API_KEY" ]; then
echo "Error: OPENAI_API_KEY environment variable not set"
echo "Usage: export OPENAI_API_KEY='your-key-here'"
exit 1
fi
echo "=========================================="
echo "Legal Contract Analyzer - Comprehensive Test"
echo "=========================================="
echo ""
echo "This test validates the multi-agent orchestrator workflow"
echo "for comprehensive legal contract analysis."
echo ""
echo "Starting test..."
echo ""
# Test 1: Basic functionality test
echo "=== TEST 1: Basic Functionality ==="
echo "Testing contract analysis workflow..."
echo ""
# Run the legal contract analyzer
python -m cleveragents run \
-c examples/legal_contract_metadata_extractor.yaml \
-p "sample_contract.txt" \
--unsafe 2>&1 | tee /tmp/contract_test_output.log
TEST_EXIT_CODE=${PIPESTATUS[0]}
if [ $TEST_EXIT_CODE -eq 0 ]; then
echo ""
echo "✅ TEST 1 PASSED: System executed successfully"
else
echo ""
echo "❌ TEST 1 FAILED: System execution failed with exit code $TEST_EXIT_CODE"
echo "Check /tmp/contract_test_output.log for details"
exit 1
fi
# Test 2: Check if agent simulation is working
echo ""
echo "=== TEST 2: Agent Simulation Verification ==="
echo "Checking if orchestrator simulates specialist agents..."
if grep -q "FILE_LOADER AGENT" /tmp/contract_test_output.log; then
echo "✅ FILE_LOADER agent simulation found"
else
echo "❌ FILE_LOADER agent simulation not found"
fi
if grep -q "TEXT_PREPROCESSOR AGENT\|CONTRACT_ANALYZER AGENT\|RISK_ASSESSOR AGENT\|JSON_FORMATTER AGENT" /tmp/contract_test_output.log; then
echo "✅ Analysis agent simulation found"
else
echo "❌ Analysis agent simulation not found (may continue in next interaction)"
fi
# Test 3: Check tool execution
echo ""
echo "=== TEST 3: Tool Execution Verification ==="
echo "Checking if file reading tool executed properly..."
if grep -q "TOOL_EXECUTE:file_read" /tmp/contract_test_output.log; then
echo "✅ File reading tool execution command found"
else
echo "⚠️ File reading tool execution command not found"
fi
# Test 4: Check for contract content
echo ""
echo "=== TEST 4: Contract Loading Validation ==="
echo "Checking if contract was loaded..."
if grep -q "SERVICE AGREEMENT" /tmp/contract_test_output.log; then
echo "✅ Contract content loaded successfully"
else
echo "❌ Contract content not found in output"
fi
# Test 5: Check for parties extraction
echo ""
echo "=== TEST 5: Metadata Extraction Validation ==="
echo "Checking if contract metadata was extracted..."
if grep -q "TechCorp Solutions" /tmp/contract_test_output.log; then
echo "✅ Party extraction working (TechCorp Solutions found)"
else
echo "❌ Party extraction not working"
fi
if grep -q "Global Enterprises" /tmp/contract_test_output.log; then
echo "✅ Party extraction working (Global Enterprises found)"
else
echo "❌ Party extraction not working"
fi
if grep -q "\$5,000" /tmp/contract_test_output.log; then
echo "✅ Financial terms extracted (payment amount found)"
else
echo "⚠️ Financial terms extraction may need additional processing"
fi
# Test 6: Performance test
echo ""
echo "=== TEST 6: Performance Check ==="
echo "Checking if processing completed within reasonable time..."
if grep -q "Single-shot processing complete" /tmp/contract_test_output.log; then
echo "✅ Processing completed successfully"
else
echo "❌ Processing may not have completed"
fi
echo ""
echo "=========================================="
echo "Test Summary:"
echo "=========================================="
# Count agent simulations
AGENT_MODES=$(grep -c "===.*AGENT.*===>" /tmp/contract_test_output.log)
echo "Agent simulations found: $AGENT_MODES"
# Check for successful processing
if grep -q "TechCorp Solutions" /tmp/contract_test_output.log && grep -q "Global Enterprises" /tmp/contract_test_output.log; then
echo "✅ Contract parties successfully extracted"
fi
if grep -q "SERVICE AGREEMENT" /tmp/contract_test_output.log; then
echo "✅ Contract content successfully loaded"
fi
# Overall assessment
if [ $TEST_EXIT_CODE -eq 0 ]; then
echo ""
echo "🎉 TESTS PASSED! Legal Contract Analyzer is operational!"
echo ""
echo "The system successfully:"
echo " ✅ Loads contract files using tool integration"
echo " ✅ Processes through orchestrator-based workflow"
echo " ✅ Extracts contract metadata (parties, dates, terms)"
echo " ✅ Uses multi-agent simulation pattern"
echo " ✅ Handles workflow orchestration properly"
echo ""
echo "Note: For complete analysis with all stages (preprocessing, metadata,"
echo "risk assessment, JSON formatting), use interactive mode to continue"
echo "the conversation through all workflow stages."
else
echo ""
echo "❌ TESTS FAILED - Review the output above"
echo "Check /tmp/contract_test_output.log for detailed logs"
fi
echo ""
echo "=========================================="
echo "Test Complete!"
echo "=========================================="
# Clean up
rm -f /tmp/contract_test_output.log