Files
temp/tests/features/steps/tool_agent_context_steps.py
T

638 lines
23 KiB
Python

"""Step definitions for Tool Agent and Application Context Updates tests."""
import asyncio
from pathlib import Path
from behave import given, then, when
from behave.runner import Context
from cleveragents.agents.tool import _CONTEXT_UPDATES, ToolAgent
from cleveragents.context_manager import ContextManager
from cleveragents.core.application import ReactiveCleverAgentsApp
from cleveragents.reactive.stream_router import (
ReactiveStreamRouter,
StreamConfig,
StreamMessage,
)
@given("I have a configured CleverAgents application")
def step_configured_application(context: Context) -> None:
"""Set up a configured CleverAgents application."""
# Create a minimal configuration
config_content = """
agents:
test_agent:
type: tool
tool_type: python
code: |
result = "Test agent executed"
routes:
main:
type: stream
stream_type: cold
operators:
- type: map
params:
agent: test_agent
publications:
- __output__
merges:
- sources: [__input__]
target: main
"""
# Create temporary config file
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(config_content)
config_path = Path(f.name)
context.app = ReactiveCleverAgentsApp([config_path], verbose=False, unsafe=True)
context.config_path = config_path
@given("I have a tool agent with Python code")
def step_tool_agent_with_code(context: Context) -> None:
"""Create a tool agent with Python code."""
from cleveragents.templates.renderer import TemplateRenderer
config = {"tools": [{"name": "test_tool", "code": 'result = "Executed"'}]}
template_renderer = TemplateRenderer()
context.tool_agent = ToolAgent(name="test_tool", config=config, template_renderer=template_renderer)
@when("the code updates the context dictionary")
def step_code_updates_context(context: Context) -> None:
"""Execute code that updates context."""
# Update the tool's code to modify context
new_code = """
context["updated_key"] = "updated_value"
context["counter"] = context.get("counter", 0) + 1
result = "Context updated"
"""
context.tool_agent.tools[0]["code"] = new_code
# Also update the python_tools dict which is actually used for execution
context.tool_agent.python_tools[context.tool_agent.tools[0]["name"]] = new_code
# Clear global updates
_CONTEXT_UPDATES.clear()
# Execute with context
context.test_context = {"initial": "value"}
async def run():
return await context.tool_agent.process_message(message="test", context=context.test_context)
context.result = asyncio.run(run())
@then("the context changes should be saved globally")
def step_context_saved_globally(context: Context) -> None:
"""Verify context changes are saved globally."""
# Check if context was modified
assert "updated_key" in context.test_context
assert context.test_context["updated_key"] == "updated_value"
@then("the changes should be available after execution")
def step_changes_available_after(context: Context) -> None:
"""Verify changes persist after execution."""
assert context.test_context.get("counter") == 1
assert context.test_context.get("initial") == "value"
@when("the code is executed without providing context")
def step_execute_without_context(context: Context) -> None:
"""Execute tool without context."""
_CONTEXT_UPDATES.clear()
async def run():
return await context.tool_agent.process_message(message="test", context=None)
context.result = asyncio.run(run())
@then("the execution should complete without errors")
def step_execution_completes_without_errors(context: Context) -> None:
"""Verify execution completes successfully."""
assert context.result is not None
@then("no context updates should be saved")
def step_no_context_updates_saved(context: Context) -> None:
"""Verify no context updates were saved."""
assert len(_CONTEXT_UPDATES) == 0
@given("I have multiple tool agents")
def step_multiple_tool_agents(context: Context) -> None:
"""Create multiple tool agents."""
from cleveragents.templates.renderer import TemplateRenderer
template_renderer = TemplateRenderer()
context.tool_agents = [
ToolAgent(
name="tool1",
config={
"tools": [
{
"name": "tool1",
"code": 'context["tool1_key"] = "value1"\nresult = "Tool 1"',
}
]
},
template_renderer=template_renderer,
),
ToolAgent(
name="tool2",
config={
"tools": [
{
"name": "tool2",
"code": 'context["tool2_key"] = "value2"\nresult = "Tool 2"',
}
]
},
template_renderer=template_renderer,
),
ToolAgent(
name="tool3",
config={
"tools": [
{
"name": "tool3",
"code": 'context["tool3_key"] = "value3"\nresult = "Tool 3"',
}
]
},
template_renderer=template_renderer,
),
]
@when("each tool updates different context keys")
def step_each_tool_updates_context(context: Context) -> None:
"""Execute each tool with context updates."""
_CONTEXT_UPDATES.clear()
context.shared_context = {}
async def run():
for agent in context.tool_agents:
await agent.process_message(message="test", context=context.shared_context)
asyncio.run(run())
@then("all context updates should be collected in _CONTEXT_UPDATES")
def step_all_updates_collected(context: Context) -> None:
"""Verify all updates are collected."""
# Updates might be collected in the shared context directly
assert "tool1_key" in context.shared_context
assert "tool2_key" in context.shared_context
assert "tool3_key" in context.shared_context
@then("the updates should be merged into the global context")
def step_updates_merged_global(context: Context) -> None:
"""Verify updates are merged into global context."""
assert context.shared_context["tool1_key"] == "value1"
assert context.shared_context["tool2_key"] == "value2"
assert context.shared_context["tool3_key"] == "value3"
@given("I have an application with context-aware agents")
def step_application_with_context_agents(context: Context) -> None:
"""Create application with context-aware agents."""
# Application already created in background
pass
@when("I run the application in single-shot mode")
def step_run_application_single_shot(context: Context) -> None:
"""Run application in single-shot mode."""
async def run_async():
return await context.app.run_single_shot("Test message")
context.single_shot_result = asyncio.run(run_async())
@then("context updates from all streams should be collected")
def step_context_updates_collected_from_streams(context: Context) -> None:
"""Verify context updates are collected from streams."""
# Check that the app's global context exists
assert context.app.config is not None
assert hasattr(context.app.config, "global_context")
@then("the global context should be updated with all changes")
def step_global_context_updated(context: Context) -> None:
"""Verify global context contains updates."""
# Global context should exist and be a dict
assert isinstance(context.app.config.global_context, dict)
@given("I have a StreamMessage with context metadata")
def step_stream_message_with_context(context: Context) -> None:
"""Create a StreamMessage with context metadata."""
context.original_context = {"key": "value", "mutable": [1, 2, 3]}
context.stream_message = StreamMessage(
content="Test content",
metadata={"context": context.original_context},
source_stream="test",
)
@when("the message is copied with modifications")
def step_message_copied_with_mods(context: Context) -> None:
"""Copy message with modifications."""
context.copied_message = context.stream_message.copy_with(content="Modified content")
@then("the context reference should be preserved")
def step_context_reference_preserved(context: Context) -> None:
"""Verify context reference is preserved."""
# Check that context in metadata points to same object
assert "context" in context.copied_message.metadata
assert context.copied_message.metadata["context"] is context.original_context
@then("modifications to the context should affect the original")
def step_context_mods_affect_original(context: Context) -> None:
"""Verify context modifications affect original."""
# Modify through copied message's context
context.copied_message.metadata["context"]["new_key"] = "new_value"
# Check original has the modification
assert context.original_context["new_key"] == "new_value"
@given("I have a stream router with global context")
def step_stream_router_with_global_context(context: Context) -> None:
"""Create stream router with global context."""
context.stream_router = ReactiveStreamRouter()
context.global_context_ref = {"global": "context"}
context.stream_router._global_context_ref = context.global_context_ref
@when("messages flow through the router")
def step_messages_flow_through_router(context: Context) -> None:
"""Send messages through the router."""
# Create streams
context.stream_router.create_stream(StreamConfig(name="input"))
context.stream_router.create_stream(StreamConfig(name="output"))
# Send message
context.stream_router.send_message("input", "Test message", {"context": context.global_context_ref})
@then("the global context reference should be maintained")
def step_global_context_ref_maintained(context: Context) -> None:
"""Verify global context reference is maintained."""
assert hasattr(context.stream_router, "_global_context_ref")
assert context.stream_router._global_context_ref is context.global_context_ref
@then("context updates should be applied to the same object")
def step_context_updates_same_object(context: Context) -> None:
"""Verify updates apply to same context object."""
# Modify the global context
context.global_context_ref["updated"] = True
# Check it's the same object
assert context.stream_router._global_context_ref["updated"] is True
@given("I have context updates with different writing_stage values")
def step_context_updates_with_writing_stages(context: Context) -> None:
"""Create context updates with various writing_stage values."""
context.context_updates = [
{"writing_stage": "intro", "data": "intro_data"},
{"writing_stage": "planning", "data": "planning_data"},
{"writing_stage": "drafting", "data": "drafting_data"},
{"writing_stage": "intro", "other": "value"},
{"writing_stage": "reviewing", "data": "reviewing_data"},
]
@when("the application processes the updates")
def step_application_processes_updates(context: Context) -> None:
"""Process context updates like the application does."""
context.merged_context = {}
for update in context.context_updates:
if update.get("writing_stage") != "intro":
context.merged_context.update(update)
@then("only non-intro writing_stage updates should be merged")
def step_non_intro_updates_merged(context: Context) -> None:
"""Verify only non-intro updates are merged."""
assert "intro_data" not in context.merged_context.values()
assert context.merged_context.get("writing_stage") != "intro"
@then("intro stage updates should be ignored")
def step_intro_updates_ignored(context: Context) -> None:
"""Verify intro updates are ignored."""
# Since each update overwrites the previous one (same keys),
# we should only have the last non-intro update
assert "intro_data" not in context.merged_context.values()
# The last update was 'reviewing' so we should have that
assert context.merged_context.get("writing_stage") == "reviewing"
assert context.merged_context.get("data") == "reviewing_data"
@given("I run the CLI with --context option")
def step_run_cli_with_context_option(context: Context) -> None:
"""Simulate running CLI with context option."""
import tempfile
context.temp_dir = Path(tempfile.mkdtemp())
context.cli_context_name = "test_cli_context"
context.cli_prompt = "Test prompt"
@when("the command completes successfully")
def step_cli_command_completes(context: Context) -> None:
"""Simulate successful CLI command completion."""
# Simulate context manager behavior
context.cli_context_manager = ContextManager(context.cli_context_name, context.temp_dir)
context.cli_context_manager.add_message("user", context.cli_prompt)
context.cli_context_manager.add_message("assistant", "Test response")
context.cli_context_manager.save()
@then("the conversation should be saved to the context")
def step_conversation_saved_to_context(context: Context) -> None:
"""Verify conversation is saved."""
assert len(context.cli_context_manager.messages) == 2
assert context.cli_context_manager.messages[0]["content"] == context.cli_prompt
@then("the global context should be persisted")
def step_global_context_persisted(context: Context) -> None:
"""Verify global context is persisted."""
context.cli_context_manager.save_global_context({"test": "global"})
# Reload and verify
new_manager = ContextManager(context.cli_context_name, context.temp_dir)
assert new_manager.global_context.get("test") == "global"
@given("I have an existing context with conversation history")
def step_existing_context_with_history(context: Context) -> None:
"""Create existing context with history."""
import tempfile
context.temp_dir = Path(tempfile.mkdtemp())
context.existing_context_name = "existing_context"
manager = ContextManager(context.existing_context_name, context.temp_dir)
manager.add_message("user", "Previous question")
manager.add_message("assistant", "Previous answer")
manager.save_global_context({"state": "previous"})
manager.save()
@when("I run the CLI with the same context name")
def step_run_cli_same_context(context: Context) -> None:
"""Run CLI with same context name."""
context.reloaded_manager = ContextManager(context.existing_context_name, context.temp_dir)
@then("the previous global context should be restored")
def step_previous_global_context_restored(context: Context) -> None:
"""Verify previous global context is restored."""
assert context.reloaded_manager.global_context.get("state") == "previous"
@then("the conversation should continue from the previous state")
def step_conversation_continues(context: Context) -> None:
"""Verify conversation continues from previous state."""
assert len(context.reloaded_manager.messages) == 2
assert context.reloaded_manager.messages[0]["content"] == "Previous question"
@given("I start an interactive session with a context manager")
def step_start_interactive_with_context(context: Context) -> None:
"""Start interactive session with context manager."""
import tempfile
context.temp_dir = Path(tempfile.mkdtemp())
context.interactive_context = ContextManager("interactive", context.temp_dir)
@when("I send messages and receive responses")
def step_send_receive_messages(context: Context) -> None:
"""Simulate sending and receiving messages."""
context.interactive_context.add_message("user", "Hello")
context.interactive_context.add_message("assistant", "Hi there!")
context.interactive_context.add_message("user", "How are you?")
context.interactive_context.add_message("assistant", "I'm doing well!")
@then("each exchange should be saved to the context")
def step_exchanges_saved(context: Context) -> None:
"""Verify exchanges are saved."""
assert len(context.interactive_context.messages) == 4
@then("the conversation history should be preserved")
def step_history_preserved(context: Context) -> None:
"""Verify conversation history is preserved."""
context.interactive_context.save()
# Reload and verify
new_manager = ContextManager("interactive", context.temp_dir)
assert len(new_manager.messages) == 4
@given("I have multiple agents updating context simultaneously")
def step_multiple_agents_concurrent(context: Context) -> None:
"""Set up multiple agents for concurrent updates."""
context.concurrent_contexts = []
for i in range(5):
ctx = {"agent_id": f"agent_{i}"}
context.concurrent_contexts.append(ctx)
@when("they all complete their updates")
def step_all_complete_updates(context: Context) -> None:
"""Simulate all agents completing updates."""
context.all_updates = {}
for i, ctx in enumerate(context.concurrent_contexts):
ctx[f"update_{i}"] = f"value_{i}"
context.all_updates.update(ctx)
@then("all updates should be captured without data loss")
def step_all_updates_captured(context: Context) -> None:
"""Verify all updates are captured."""
# We should have the last agent_id value (they overwrite each other)
assert "agent_id" in context.all_updates
# But we should have all the individual updates
for i in range(5):
assert f"update_{i}" in context.all_updates
assert context.all_updates[f"update_{i}"] == f"value_{i}"
@then("the final context should contain all changes")
def step_final_context_has_all_changes(context: Context) -> None:
"""Verify final context has all changes."""
# Should have agent_id + 5 update fields
assert len(context.all_updates) >= 6 # 1 agent_id + 5 updates
@given("I have a tool agent with code that modifies context in place")
def step_tool_agent_modifies_in_place(context: Context) -> None:
"""Create tool that modifies context in place."""
from cleveragents.templates.renderer import TemplateRenderer
config = {
"tools": [
{
"name": "in_place",
"code": """
context["modified"] = True
context["list"].append(4)
result = "Modified in place"
""",
}
]
}
template_renderer = TemplateRenderer()
context.in_place_tool = ToolAgent(name="in_place", config=config, template_renderer=template_renderer)
@when("the code executes with exec()")
def step_code_executes_with_exec(context: Context) -> None:
"""Execute code with exec."""
context.exec_context = {"list": [1, 2, 3], "original": True}
async def run():
return await context.in_place_tool.process_message(message="test", context=context.exec_context)
asyncio.run(run())
@then("the original context object should be modified")
def step_original_context_modified(context: Context) -> None:
"""Verify original context is modified."""
assert context.exec_context["modified"] is True
assert context.exec_context["list"] == [1, 2, 3, 4]
assert context.exec_context["original"] is True
@then("no deep copying should occur")
def step_no_deep_copying(context: Context) -> None:
"""Verify no deep copying occurred."""
# The list should have been modified in place
assert len(context.exec_context["list"]) == 4
@given("I have agents that may or may not update context")
def step_agents_optional_updates(context: Context) -> None:
"""Create agents with optional context updates."""
context.optional_updates = [
{"agent": "agent1", "update": {"key1": "value1"}},
{"agent": "agent2", "update": {}}, # Empty update
{"agent": "agent3", "update": {"key3": "value3"}},
{"agent": "agent4", "update": None}, # No update
{"agent": "agent5", "update": {"key5": "value5"}},
]
@when("some agents return empty context updates")
def step_some_agents_empty_updates(context: Context) -> None:
"""Process updates including empty ones."""
context.processed_updates = {}
for item in context.optional_updates:
update = item.get("update")
if update and isinstance(update, dict) and update:
context.processed_updates.update(update)
@then("only non-empty updates should be processed")
def step_only_non_empty_processed(context: Context) -> None:
"""Verify only non-empty updates are processed."""
assert "key1" in context.processed_updates
assert "key3" in context.processed_updates
assert "key5" in context.processed_updates
assert len(context.processed_updates) == 3
@then("the application should not crash")
def step_application_not_crash(context: Context) -> None:
"""Verify application handles empty updates gracefully."""
# The processing completed without errors
assert context.processed_updates is not None
@given("I have input, processing, and output streams")
def step_multiple_stream_types(context: Context) -> None:
"""Create multiple stream types."""
context.multi_router = ReactiveStreamRouter()
context.multi_router.create_stream("input")
context.multi_router.create_stream("processing")
context.multi_router.create_stream("output")
context.stream_contexts = {}
@when("each stream type updates context")
def step_each_stream_updates(context: Context) -> None:
"""Each stream updates context."""
context.stream_contexts["input"] = {"source": "input"}
context.stream_contexts["processing"] = {"stage": "processing"}
context.stream_contexts["output"] = {"result": "output"}
@then("updates from all stream types should be collected")
def step_updates_from_all_streams(context: Context) -> None:
"""Verify updates from all streams are collected."""
assert len(context.stream_contexts) == 3
assert "input" in context.stream_contexts
assert "processing" in context.stream_contexts
assert "output" in context.stream_contexts
@then("no updates should be lost")
def step_no_updates_lost(context: Context) -> None:
"""Verify no updates are lost."""
assert context.stream_contexts["input"]["source"] == "input"
assert context.stream_contexts["processing"]["stage"] == "processing"
assert context.stream_contexts["output"]["result"] == "output"
@given("I specify a custom context directory")
def step_specify_custom_directory(context: Context) -> None:
"""Specify custom context directory."""
import tempfile
context.custom_dir = Path(tempfile.mkdtemp()) / "custom_contexts"
context.custom_dir.mkdir(parents=True, exist_ok=True)
@when("I use context management commands")
def step_use_context_commands(context: Context) -> None:
"""Use context management commands with custom directory."""
context.custom_manager = ContextManager("custom", context.custom_dir)
context.custom_manager.add_message("user", "Custom directory test")
context.custom_manager.save()
@then("all operations should use the custom directory")
def step_operations_use_custom_dir(context: Context) -> None:
"""Verify operations use custom directory."""
assert context.custom_manager.context_dir.parent == context.custom_dir
@then("contexts should be isolated from default location")
def step_contexts_isolated(context: Context) -> None:
"""Verify contexts are isolated from default location."""
# Default location should not contain this context
default_dir = Path.home() / ".cleveragents" / "context"
if default_dir.exists():
assert "custom" not in [d.name for d in default_dir.iterdir()]