mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-27 17:49:22 +00:00
- Introduced a new `create_agent` command for interactive agent definition. - Added `agent_tui.py` for a conversational TUI supporting multi-agent interactions. - Updated CLI to support agent creation and training workflows. - Enhanced `.gitignore` to exclude demo files and configuration artifacts. - Implemented a benchmark runner for testing agent performance against defined cases. This commit lays the groundwork for a more interactive and user-friendly experience in managing agents within the CrewAI framework.
623 lines
22 KiB
Python
623 lines
22 KiB
Python
"""Tests for GAP-80, GAP-81, GAP-82, GAP-100, GAP-101, GAP-112, GAP-113.
|
|
|
|
Covers:
|
|
- GAP-80: Workflow user confirmation flow (pending list, confirm, reject)
|
|
- GAP-81: Executable Python Flow code generation
|
|
- GAP-82: match_workflow() consults discovered flows
|
|
- GAP-100: Scope classification persisted with canonical memories
|
|
- GAP-101: Shared canonical memories tagged read-only and skipped
|
|
- GAP-112: Raw memories pruned after dreaming consolidation
|
|
- GAP-113: Workflow detection threshold is 5 (not 3)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import textwrap
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
|
|
import pytest
|
|
|
|
from crewai.new_agent import NewAgent, AgentSettings
|
|
from crewai.new_agent.dreaming import (
|
|
DreamingEngine,
|
|
_classify_scope,
|
|
SCOPE_GLOBAL,
|
|
SCOPE_USER,
|
|
SCOPE_CONVERSATION,
|
|
)
|
|
from crewai.new_agent.models import ProvenanceEntry
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
def _make_agent(**kwargs: Any) -> NewAgent:
|
|
defaults = dict(role="TestAgent", goal="testing", memory=False)
|
|
defaults.update(kwargs)
|
|
return NewAgent(**defaults)
|
|
|
|
|
|
def _make_engine(agent: NewAgent | None = None) -> DreamingEngine:
|
|
if agent is None:
|
|
agent = _make_agent()
|
|
return agent._dreaming_engine
|
|
|
|
|
|
def _make_provenance_entries(tool_sequence: list[str], repeat: int) -> list[ProvenanceEntry]:
|
|
"""Create provenance entries that repeat a tool sequence `repeat` times."""
|
|
entries: list[ProvenanceEntry] = []
|
|
for _ in range(repeat):
|
|
for tool in tool_sequence:
|
|
entries.append(ProvenanceEntry(
|
|
action="tool_call",
|
|
inputs={"tool": tool},
|
|
))
|
|
entries.append(ProvenanceEntry(action="response"))
|
|
return entries
|
|
|
|
|
|
# ── GAP-80: Workflow user confirmation flow ──────────────────
|
|
|
|
|
|
class TestGAP80WorkflowConfirmation:
|
|
"""Workflows should go to a pending list, not auto-save."""
|
|
|
|
def test_pending_workflows_initially_empty(self):
|
|
engine = _make_engine()
|
|
assert engine._pending_workflows == []
|
|
assert engine.get_pending_workflows() == []
|
|
|
|
def test_propose_workflow_adds_to_pending(self):
|
|
engine = _make_engine()
|
|
wf = {"tools": ["search", "summarize"], "count": 5}
|
|
engine._propose_workflow(wf)
|
|
pending = engine.get_pending_workflows()
|
|
assert len(pending) == 1
|
|
assert pending[0]["tools"] == ["search", "summarize"]
|
|
assert "description" in pending[0]
|
|
|
|
def test_propose_workflow_does_not_auto_save(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
engine = _make_engine()
|
|
wf = {"tools": ["search", "summarize"], "count": 5}
|
|
engine._propose_workflow(wf)
|
|
# No recipe file should exist
|
|
flows_dir = tmp_path / ".crewai" / "flows"
|
|
json_files = list(flows_dir.glob("*.json")) if flows_dir.exists() else []
|
|
assert len(json_files) == 0
|
|
|
|
def test_confirm_workflow_saves_recipe(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
engine = _make_engine()
|
|
wf = {"tools": ["search", "summarize"], "count": 5}
|
|
engine._propose_workflow(wf)
|
|
|
|
confirmed = engine.confirm_workflow(0)
|
|
assert confirmed is not None
|
|
assert confirmed["tools"] == ["search", "summarize"]
|
|
|
|
# Pending list should now be empty
|
|
assert engine.get_pending_workflows() == []
|
|
|
|
# Recipe file should be created
|
|
flows_dir = tmp_path / ".crewai" / "flows"
|
|
json_files = [f for f in flows_dir.glob("*.json") if f.name != "manifest.json"]
|
|
assert len(json_files) >= 1
|
|
|
|
def test_reject_workflow_removes_from_pending(self):
|
|
engine = _make_engine()
|
|
wf = {"tools": ["search", "summarize"], "count": 5}
|
|
engine._propose_workflow(wf)
|
|
assert len(engine.get_pending_workflows()) == 1
|
|
|
|
rejected = engine.reject_workflow(0)
|
|
assert rejected is not None
|
|
assert rejected["tools"] == ["search", "summarize"]
|
|
assert engine.get_pending_workflows() == []
|
|
|
|
def test_confirm_invalid_index_returns_none(self):
|
|
engine = _make_engine()
|
|
assert engine.confirm_workflow(0) is None
|
|
assert engine.confirm_workflow(-1) is None
|
|
assert engine.confirm_workflow(99) is None
|
|
|
|
def test_reject_invalid_index_returns_none(self):
|
|
engine = _make_engine()
|
|
assert engine.reject_workflow(0) is None
|
|
assert engine.reject_workflow(-1) is None
|
|
|
|
def test_multiple_pending_workflows(self):
|
|
engine = _make_engine()
|
|
engine._propose_workflow({"tools": ["a", "b"], "count": 5})
|
|
engine._propose_workflow({"tools": ["c", "d"], "count": 6})
|
|
assert len(engine.get_pending_workflows()) == 2
|
|
|
|
# Confirm the first one
|
|
confirmed = engine.confirm_workflow(0)
|
|
assert confirmed["tools"] == ["a", "b"]
|
|
assert len(engine.get_pending_workflows()) == 1
|
|
assert engine.get_pending_workflows()[0]["tools"] == ["c", "d"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dream_does_not_auto_save_workflows(self, tmp_path, monkeypatch):
|
|
"""dream() should propose workflows but never auto-save them."""
|
|
monkeypatch.chdir(tmp_path)
|
|
agent = _make_agent(
|
|
settings=AgentSettings(self_improving=True, memory_enabled=False),
|
|
)
|
|
engine = agent._dreaming_engine
|
|
|
|
# Set up provenance with a repeated pattern (5+ times)
|
|
mock_executor = MagicMock()
|
|
mock_executor.provenance_log = _make_provenance_entries(
|
|
["search", "parse"], repeat=6,
|
|
)
|
|
# _executor is a property; set the underlying dict entry
|
|
cid = agent._default_conversation_id
|
|
agent._executors[cid] = mock_executor
|
|
|
|
result = await engine.dream()
|
|
assert result["workflows_detected"] >= 1
|
|
|
|
# Should be pending, NOT saved
|
|
assert len(engine.get_pending_workflows()) >= 1
|
|
flows_dir = tmp_path / ".crewai" / "flows"
|
|
json_files = list(flows_dir.glob("*.json")) if flows_dir.exists() else []
|
|
assert len(json_files) == 0
|
|
|
|
|
|
# ── GAP-81: Executable Flow code generation ──────────────────
|
|
|
|
|
|
class TestGAP81FlowCodeGeneration:
|
|
"""confirm_workflow() should generate a .py Flow file."""
|
|
|
|
def test_generate_flow_code_creates_py_file(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
engine = _make_engine()
|
|
wf = {"tools": ["search_web", "read_file", "summarize"], "count": 5}
|
|
|
|
path = engine._generate_flow_code(wf)
|
|
assert path is not None
|
|
assert path.endswith(".py")
|
|
assert os.path.exists(path)
|
|
|
|
content = Path(path).read_text()
|
|
assert "class " in content
|
|
assert "@start()" in content
|
|
assert "search_web" in content
|
|
assert "read_file" in content
|
|
assert "summarize" in content
|
|
assert "from crewai.flow.flow import Flow, start, listen" in content
|
|
|
|
def test_generate_flow_code_empty_tools_returns_none(self):
|
|
engine = _make_engine()
|
|
result = engine._generate_flow_code({"tools": [], "count": 5})
|
|
assert result is None
|
|
|
|
def test_confirm_workflow_also_generates_flow_code(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
engine = _make_engine()
|
|
wf = {"tools": ["alpha", "beta"], "count": 5}
|
|
engine._propose_workflow(wf)
|
|
engine.confirm_workflow(0)
|
|
|
|
flows_dir = tmp_path / ".crewai" / "flows"
|
|
py_files = list(flows_dir.glob("workflow_*.py"))
|
|
assert len(py_files) == 1
|
|
|
|
content = py_files[0].read_text()
|
|
assert "class " in content
|
|
assert "@start()" in content
|
|
|
|
def test_generated_flow_has_correct_steps(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
engine = _make_engine()
|
|
wf = {"tools": ["step_a", "step_b", "step_c"], "count": 7}
|
|
path = engine._generate_flow_code(wf)
|
|
content = Path(path).read_text()
|
|
|
|
# Should have 3 step methods
|
|
assert "step_1_step_a" in content
|
|
assert "step_2_step_b" in content
|
|
assert "step_3_step_c" in content
|
|
|
|
# First step uses @start, others use @listen
|
|
assert "@start()" in content
|
|
assert "@listen" in content
|
|
|
|
|
|
# ── GAP-82: match_workflow() ─────────────────────────────────
|
|
|
|
|
|
class TestGAP82MatchWorkflow:
|
|
"""match_workflow() should check user messages against discovered flows."""
|
|
|
|
def test_no_discovered_flows_returns_none(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
engine = _make_engine()
|
|
assert engine._discovered_flows == []
|
|
assert engine.match_workflow("search and summarize articles") is None
|
|
|
|
def test_match_with_sufficient_overlap(self):
|
|
engine = _make_engine()
|
|
engine._discovered_flows = [
|
|
{
|
|
"name": "search_summarize",
|
|
"description": "Repeated pattern (5x): search -> summarize articles",
|
|
"tools": ["search", "summarize"],
|
|
},
|
|
]
|
|
result = engine.match_workflow("I want to search and summarize articles")
|
|
assert result is not None
|
|
assert result["name"] == "search_summarize"
|
|
|
|
def test_no_match_with_insufficient_overlap(self):
|
|
engine = _make_engine()
|
|
engine._discovered_flows = [
|
|
{
|
|
"name": "search_summarize",
|
|
"description": "Repeated pattern (5x): search -> summarize articles",
|
|
"tools": ["search", "summarize"],
|
|
},
|
|
]
|
|
# Only one overlapping word ("search") is below the threshold of 3
|
|
result = engine.match_workflow("please search now")
|
|
assert result is None
|
|
|
|
def test_match_ignores_stop_words(self):
|
|
engine = _make_engine()
|
|
engine._discovered_flows = [
|
|
{
|
|
"name": "fetch_parse_save",
|
|
"description": "fetch data parse results save output",
|
|
"tools": ["fetch", "parse", "save"],
|
|
},
|
|
]
|
|
# "the", "and", "to" are stop words, should not count
|
|
result = engine.match_workflow("fetch parse save")
|
|
assert result is not None
|
|
|
|
def test_match_returns_first_matching_flow(self):
|
|
engine = _make_engine()
|
|
engine._discovered_flows = [
|
|
{"name": "flow1", "description": "alpha beta gamma delta", "tools": []},
|
|
{"name": "flow2", "description": "alpha beta gamma epsilon", "tools": []},
|
|
]
|
|
result = engine.match_workflow("alpha beta gamma something")
|
|
assert result is not None
|
|
assert result["name"] == "flow1"
|
|
|
|
|
|
# ── GAP-100: Scope persisted with canonical memories ─────────
|
|
|
|
|
|
class TestGAP100ScopePersistence:
|
|
"""Canonical memories should include scope in metadata."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canonical_memory_includes_scope_metadata(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
agent = _make_agent(
|
|
settings=AgentSettings(self_improving=True, memory_enabled=True),
|
|
)
|
|
engine = agent._dreaming_engine
|
|
|
|
mock_memory = MagicMock()
|
|
object.__setattr__(agent, "_memory_instance", mock_memory)
|
|
|
|
# Patch _consolidate_memories to return controlled output
|
|
async def fake_consolidate(memories):
|
|
return ["Python is a great language"]
|
|
|
|
engine._consolidate_memories = fake_consolidate
|
|
|
|
# Create mock memories to process
|
|
mock_mem = MagicMock()
|
|
mock_mem.id = "m1"
|
|
mock_mem.content = "raw memory"
|
|
mock_mem.metadata = {}
|
|
mock_memory.recall.return_value = [mock_mem]
|
|
|
|
await engine.dream()
|
|
|
|
# Verify remember was called with metadata including scope
|
|
assert mock_memory.remember.called
|
|
remember_call = mock_memory.remember.call_args
|
|
# Check the metadata kwarg
|
|
if "metadata" in (remember_call.kwargs or {}):
|
|
meta = remember_call.kwargs["metadata"]
|
|
assert "type" in meta
|
|
assert meta["type"] == "canonical"
|
|
assert "scope" in meta
|
|
assert meta["scope"] in (SCOPE_GLOBAL, SCOPE_USER, SCOPE_CONVERSATION)
|
|
assert "dreaming_cycle" in meta
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_scoped_memory_tagged_correctly(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
agent = _make_agent(
|
|
settings=AgentSettings(self_improving=True, memory_enabled=True),
|
|
)
|
|
engine = agent._dreaming_engine
|
|
|
|
mock_memory = MagicMock()
|
|
object.__setattr__(agent, "_memory_instance", mock_memory)
|
|
|
|
mock_mem = MagicMock()
|
|
mock_mem.id = "m1"
|
|
mock_mem.content = "raw memory"
|
|
mock_mem.metadata = {}
|
|
mock_memory.recall.return_value = [mock_mem]
|
|
|
|
async def fake_consolidate(memories):
|
|
return ["I prefer dark mode for my settings"]
|
|
|
|
engine._consolidate_memories = fake_consolidate
|
|
|
|
await engine.dream()
|
|
|
|
assert mock_memory.remember.called
|
|
remember_call = mock_memory.remember.call_args
|
|
if "metadata" in (remember_call.kwargs or {}):
|
|
assert remember_call.kwargs["metadata"]["scope"] == SCOPE_USER
|
|
|
|
|
|
# ── GAP-101: Shared canonical memories read-only ─────────────
|
|
|
|
|
|
class TestGAP101SharedReadOnly:
|
|
"""Shared memories should be tagged read-only and skipped during consolidation."""
|
|
|
|
def test_shared_memory_has_read_only_tag_in_content(self):
|
|
"""_share_with_coworkers should prefix content with [shared:read-only]."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
|
|
coworker = _make_agent(role="Coworker")
|
|
cw_memory = MagicMock()
|
|
coworker._memory_instance = cw_memory
|
|
agent._resolved_coworkers = [coworker]
|
|
|
|
engine._share_with_coworkers(["Important fact"])
|
|
|
|
assert cw_memory.remember.called
|
|
call_args = cw_memory.remember.call_args
|
|
value = call_args.args[0] if call_args.args else call_args.kwargs.get("value", "")
|
|
assert "[shared:read-only]" in value
|
|
|
|
def test_shared_memory_has_read_only_metadata(self):
|
|
"""_share_with_coworkers should include read_only=True in metadata."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
|
|
coworker = _make_agent(role="Coworker")
|
|
cw_memory = MagicMock()
|
|
coworker._memory_instance = cw_memory
|
|
agent._resolved_coworkers = [coworker]
|
|
|
|
engine._share_with_coworkers(["Important fact"])
|
|
|
|
assert cw_memory.remember.called
|
|
call_kwargs = cw_memory.remember.call_args.kwargs or {}
|
|
if "metadata" in call_kwargs:
|
|
meta = call_kwargs["metadata"]
|
|
assert meta.get("read_only") is True
|
|
assert meta.get("type") == "canonical_shared"
|
|
assert meta.get("source_agent") == "TestAgent"
|
|
|
|
def test_read_only_memories_skipped_by_content_prefix(self):
|
|
"""_get_recent_memories should skip memories starting with [shared:read-only]."""
|
|
engine = _make_engine()
|
|
mock_memory = MagicMock()
|
|
|
|
mem_shared = MagicMock()
|
|
mem_shared.id = "shared-1"
|
|
mem_shared.content = "[shared:read-only][shared from Other] some fact"
|
|
mem_shared.metadata = {}
|
|
|
|
mem_normal = MagicMock()
|
|
mem_normal.id = "normal-1"
|
|
mem_normal.content = "A normal memory"
|
|
mem_normal.metadata = {}
|
|
|
|
mock_memory.recall.return_value = [mem_shared, mem_normal]
|
|
|
|
contents, ids = engine._get_recent_memories(mock_memory)
|
|
assert len(contents) == 1
|
|
assert contents[0] == "A normal memory"
|
|
assert "normal-1" in ids
|
|
assert "shared-1" not in ids
|
|
|
|
def test_read_only_memories_skipped_by_metadata(self):
|
|
"""_get_recent_memories should skip memories with read_only=True in metadata."""
|
|
engine = _make_engine()
|
|
mock_memory = MagicMock()
|
|
|
|
mem_readonly = MagicMock()
|
|
mem_readonly.id = "readonly-1"
|
|
mem_readonly.content = "Some shared fact"
|
|
mem_readonly.metadata = {"read_only": True}
|
|
|
|
mem_normal = MagicMock()
|
|
mem_normal.id = "normal-1"
|
|
mem_normal.content = "A normal memory"
|
|
mem_normal.metadata = {}
|
|
|
|
mock_memory.recall.return_value = [mem_readonly, mem_normal]
|
|
|
|
contents, ids = engine._get_recent_memories(mock_memory)
|
|
assert len(contents) == 1
|
|
assert contents[0] == "A normal memory"
|
|
|
|
|
|
# ── GAP-112: Raw memory pruning ──────────────────────────────
|
|
|
|
|
|
class TestGAP112MemoryPruning:
|
|
"""Consolidated raw memories should be pruned (keeping audit trail)."""
|
|
|
|
def test_prune_does_nothing_with_few_ids(self):
|
|
"""Should keep all if processed count <= KEEP_RECENT (20)."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
mock_memory = MagicMock()
|
|
agent._memory_instance = mock_memory
|
|
|
|
# 15 IDs < 20 threshold
|
|
ids = {str(i) for i in range(15)}
|
|
engine._prune_processed_memories(ids)
|
|
mock_memory.delete.assert_not_called()
|
|
|
|
def test_prune_deletes_oldest_keeps_recent(self):
|
|
"""Should delete the oldest and keep the 20 most recent."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
mock_memory = MagicMock()
|
|
agent._memory_instance = mock_memory
|
|
|
|
# 25 IDs > 20 threshold => prune 5
|
|
ids = {f"mem_{i:03d}" for i in range(25)}
|
|
engine._prune_processed_memories(ids)
|
|
|
|
# Should have deleted 5 (25 - 20)
|
|
assert mock_memory.delete.call_count == 5
|
|
|
|
def test_prune_exactly_at_threshold(self):
|
|
"""Exactly 20 IDs should NOT trigger pruning."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
mock_memory = MagicMock()
|
|
agent._memory_instance = mock_memory
|
|
|
|
ids = {str(i) for i in range(20)}
|
|
engine._prune_processed_memories(ids)
|
|
mock_memory.delete.assert_not_called()
|
|
|
|
def test_prune_without_memory_instance(self):
|
|
"""Should not crash if agent has no memory instance."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
agent._memory_instance = None
|
|
|
|
# Should not raise
|
|
engine._prune_processed_memories({str(i) for i in range(30)})
|
|
|
|
def test_prune_tolerates_delete_errors(self):
|
|
"""Individual delete failures should not stop the pruning."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
mock_memory = MagicMock()
|
|
mock_memory.delete.side_effect = RuntimeError("storage error")
|
|
agent._memory_instance = mock_memory
|
|
|
|
ids = {f"mem_{i:03d}" for i in range(25)}
|
|
# Should not raise despite delete failures
|
|
engine._prune_processed_memories(ids)
|
|
assert mock_memory.delete.call_count == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dream_calls_prune(self, tmp_path, monkeypatch):
|
|
"""dream() should call _prune_processed_memories after consolidation."""
|
|
monkeypatch.chdir(tmp_path)
|
|
agent = _make_agent(
|
|
settings=AgentSettings(self_improving=True, memory_enabled=True),
|
|
)
|
|
engine = agent._dreaming_engine
|
|
|
|
mock_memory = MagicMock()
|
|
mock_mem = MagicMock()
|
|
mock_mem.id = "m1"
|
|
mock_mem.content = "test memory"
|
|
mock_mem.metadata = {}
|
|
mock_memory.recall.return_value = [mock_mem]
|
|
object.__setattr__(agent, "_memory_instance", mock_memory)
|
|
|
|
async def fake_consolidate(memories):
|
|
return ["canonical insight"]
|
|
|
|
engine._consolidate_memories = fake_consolidate
|
|
|
|
with patch.object(engine, "_prune_processed_memories") as mock_prune:
|
|
await engine.dream()
|
|
mock_prune.assert_called_once()
|
|
# Arg should be the full set of processed IDs
|
|
called_ids = mock_prune.call_args[0][0]
|
|
assert "m1" in called_ids
|
|
|
|
|
|
# ── GAP-113: Workflow detection threshold ────────────────────
|
|
|
|
|
|
class TestGAP113ThresholdFive:
|
|
"""Workflow detection should require count >= 5."""
|
|
|
|
def _set_executor(self, agent, mock_executor):
|
|
"""Helper to set a mock executor on the agent."""
|
|
cid = agent._default_conversation_id
|
|
agent._executors[cid] = mock_executor
|
|
|
|
def test_threshold_rejects_count_3(self):
|
|
"""Sequences appearing only 3 times should NOT be detected."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
|
|
mock_executor = MagicMock()
|
|
mock_executor.provenance_log = _make_provenance_entries(
|
|
["search", "parse"], repeat=3,
|
|
)
|
|
self._set_executor(agent, mock_executor)
|
|
|
|
workflows = engine._detect_workflows()
|
|
assert len(workflows) == 0
|
|
|
|
def test_threshold_rejects_count_4(self):
|
|
"""Sequences appearing only 4 times should NOT be detected."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
|
|
mock_executor = MagicMock()
|
|
mock_executor.provenance_log = _make_provenance_entries(
|
|
["search", "parse"], repeat=4,
|
|
)
|
|
self._set_executor(agent, mock_executor)
|
|
|
|
workflows = engine._detect_workflows()
|
|
assert len(workflows) == 0
|
|
|
|
def test_threshold_accepts_count_5(self):
|
|
"""Sequences appearing 5 times SHOULD be detected."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
|
|
mock_executor = MagicMock()
|
|
mock_executor.provenance_log = _make_provenance_entries(
|
|
["search", "parse"], repeat=5,
|
|
)
|
|
self._set_executor(agent, mock_executor)
|
|
|
|
workflows = engine._detect_workflows()
|
|
assert len(workflows) == 1
|
|
assert workflows[0]["count"] == 5
|
|
assert workflows[0]["tools"] == ["search", "parse"]
|
|
|
|
def test_threshold_accepts_count_above_5(self):
|
|
"""Sequences appearing more than 5 times should also be detected."""
|
|
agent = _make_agent()
|
|
engine = agent._dreaming_engine
|
|
|
|
mock_executor = MagicMock()
|
|
mock_executor.provenance_log = _make_provenance_entries(
|
|
["fetch", "transform", "load"], repeat=8,
|
|
)
|
|
self._set_executor(agent, mock_executor)
|
|
|
|
workflows = engine._detect_workflows()
|
|
assert len(workflows) == 1
|
|
assert workflows[0]["count"] == 8
|