Fix microagent loading with trailing slashes and nested directories (#6239)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang
2025-01-15 12:07:40 -05:00
committed by GitHub
parent 8795ee6c6e
commit 179a89a211
9 changed files with 381 additions and 95 deletions

View File

@@ -8,6 +8,7 @@ from pydantic import BaseModel
from openhands.core.exceptions import (
MicroAgentValidationError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.microagent.types import MicroAgentMetadata, MicroAgentType
@@ -132,8 +133,10 @@ def load_microagents_from_dir(
]:
"""Load all microagents from the given directory.
Note, legacy repo instructions will not be loaded here.
Args:
microagent_dir: Path to the microagents directory.
microagent_dir: Path to the microagents directory (e.g. .openhands/microagents)
Returns:
Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries
@@ -145,20 +148,24 @@ def load_microagents_from_dir(
knowledge_agents = {}
task_agents = {}
# Load all agents
for file in microagent_dir.rglob('*.md'):
# skip README.md
if file.name == 'README.md':
continue
try:
agent = BaseMicroAgent.load(file)
if isinstance(agent, RepoMicroAgent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroAgent):
knowledge_agents[agent.name] = agent
elif isinstance(agent, TaskMicroAgent):
task_agents[agent.name] = agent
except Exception as e:
raise ValueError(f'Error loading agent from {file}: {e}')
# Load all agents from .openhands/microagents directory
logger.debug(f'Loading agents from {microagent_dir}')
if microagent_dir.exists():
for file in microagent_dir.rglob('*.md'):
logger.debug(f'Checking file {file}...')
# skip README.md
if file.name == 'README.md':
continue
try:
agent = BaseMicroAgent.load(file)
if isinstance(agent, RepoMicroAgent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroAgent):
knowledge_agents[agent.name] = agent
elif isinstance(agent, TaskMicroAgent):
task_agents[agent.name] = agent
logger.debug(f'Loaded agent {agent.name} from {file}')
except Exception as e:
raise ValueError(f'Error loading agent from {file}: {e}')
return repo_agents, knowledge_agents, task_agents

View File

@@ -610,10 +610,14 @@ def parse_unified_diff(text):
# - Start at line 1 in the old file and show 6 lines
# - Start at line 1 in the new file and show 6 lines
old = int(h.group(1)) # Starting line in old file
old_len = int(h.group(2)) if len(h.group(2)) > 0 else 1 # Number of lines in old file
old_len = (
int(h.group(2)) if len(h.group(2)) > 0 else 1
) # Number of lines in old file
new = int(h.group(3)) # Starting line in new file
new_len = int(h.group(4)) if len(h.group(4)) > 0 else 1 # Number of lines in new file
new_len = (
int(h.group(4)) if len(h.group(4)) > 0 else 1
) # Number of lines in new file
h = None
break
@@ -622,7 +626,9 @@ def parse_unified_diff(text):
for n in hunk:
# Each line in a unified diff starts with a space (context), + (addition), or - (deletion)
# The first character is the kind, the rest is the line content
kind = n[0] if len(n) > 0 else ' ' # Empty lines in the hunk are treated as context lines
kind = (
n[0] if len(n) > 0 else ' '
) # Empty lines in the hunk are treated as context lines
line = n[1:] if len(n) > 1 else ''
# Process the line based on its kind

View File

@@ -4,10 +4,13 @@ import copy
import json
import os
import random
import shutil
import string
import tempfile
from abc import abstractmethod
from pathlib import Path
from typing import Callable
from zipfile import ZipFile
from requests.exceptions import ConnectionError
@@ -37,9 +40,7 @@ from openhands.events.observation import (
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.microagent import (
BaseMicroAgent,
KnowledgeMicroAgent,
RepoMicroAgent,
TaskMicroAgent,
load_microagents_from_dir,
)
from openhands.runtime.plugins import (
JupyterRequirement,
@@ -228,21 +229,37 @@ class Runtime(FileEditRuntimeMixin):
def get_microagents_from_selected_repo(
self, selected_repository: str | None
) -> list[BaseMicroAgent]:
"""Load microagents from the selected repository.
If selected_repository is None, load microagents from the current workspace.
This is the main entry point for loading microagents.
"""
loaded_microagents: list[BaseMicroAgent] = []
dir_name = Path('.openhands') / 'microagents'
workspace_root = Path(self.config.workspace_mount_path_in_sandbox)
microagents_dir = workspace_root / '.openhands' / 'microagents'
repo_root = None
if selected_repository:
dir_name = Path('/workspace') / selected_repository.split('/')[1] / dir_name
repo_root = workspace_root / selected_repository.split('/')[1]
microagents_dir = repo_root / '.openhands' / 'microagents'
self.log(
'info',
f'Selected repo: {selected_repository}, loading microagents from {microagents_dir} (inside runtime)',
)
# Legacy Repo Instructions
# Check for legacy .openhands_instructions file
obs = self.read(FileReadAction(path='.openhands_instructions'))
if isinstance(obs, ErrorObservation):
obs = self.read(
FileReadAction(path=str(workspace_root / '.openhands_instructions'))
)
if isinstance(obs, ErrorObservation) and repo_root is not None:
# If the instructions file is not found in the workspace root, try to load it from the repo root
self.log(
'debug',
f'openhands_instructions not present, trying to load from {dir_name}',
f'.openhands_instructions not present, trying to load from repository {microagents_dir=}',
)
obs = self.read(
FileReadAction(path=str(dir_name / '.openhands_instructions'))
FileReadAction(path=str(repo_root / '.openhands_instructions'))
)
if isinstance(obs, FileReadObservation):
@@ -253,44 +270,40 @@ class Runtime(FileEditRuntimeMixin):
)
)
# Check for local repository microagents
files = self.list_files(str(dir_name))
self.log('info', f'Found {len(files)} local microagents.')
if 'repo.md' in files:
obs = self.read(FileReadAction(path=str(dir_name / 'repo.md')))
if isinstance(obs, FileReadObservation):
self.log('info', 'repo.md microagent loaded.')
loaded_microagents.append(
RepoMicroAgent.load(
path=str(dir_name / 'repo.md'), file_content=obs.content
)
)
# Load microagents from directory
files = self.list_files(str(microagents_dir))
if files:
self.log('info', f'Found {len(files)} files in microagents directory.')
zip_path = self.copy_from(str(microagents_dir))
microagent_folder = tempfile.mkdtemp()
if 'knowledge' in files:
knowledge_dir = dir_name / 'knowledge'
_knowledge_microagents_files = self.list_files(str(knowledge_dir))
for fname in _knowledge_microagents_files:
obs = self.read(FileReadAction(path=str(knowledge_dir / fname)))
if isinstance(obs, FileReadObservation):
self.log('info', f'knowledge/{fname} microagent loaded.')
loaded_microagents.append(
KnowledgeMicroAgent.load(
path=str(knowledge_dir / fname), file_content=obs.content
)
)
# Properly handle the zip file
with ZipFile(zip_path, 'r') as zip_file:
zip_file.extractall(microagent_folder)
# Add debug print of directory structure
self.log('debug', 'Microagent folder structure:')
for root, _, files in os.walk(microagent_folder):
relative_path = os.path.relpath(root, microagent_folder)
self.log('debug', f'Directory: {relative_path}/')
for file in files:
self.log('debug', f' File: {os.path.join(relative_path, file)}')
# Clean up the temporary zip file
zip_path.unlink()
# Load all microagents using the existing function
repo_agents, knowledge_agents, task_agents = load_microagents_from_dir(
microagent_folder
)
self.log(
'info',
f'Loaded {len(repo_agents)} repo agents, {len(knowledge_agents)} knowledge agents, and {len(task_agents)} task agents',
)
loaded_microagents.extend(repo_agents.values())
loaded_microagents.extend(knowledge_agents.values())
loaded_microagents.extend(task_agents.values())
shutil.rmtree(microagent_folder)
if 'tasks' in files:
tasks_dir = dir_name / 'tasks'
_tasks_microagents_files = self.list_files(str(tasks_dir))
for fname in _tasks_microagents_files:
obs = self.read(FileReadAction(path=str(tasks_dir / fname)))
if isinstance(obs, FileReadObservation):
self.log('info', f'tasks/{fname} microagent loaded.')
loaded_microagents.append(
TaskMicroAgent.load(
path=str(tasks_dir / fname), file_content=obs.content
)
)
return loaded_microagents
def run_action(self, action: Action) -> Observation:

View File

@@ -26,7 +26,7 @@ test_mount_path = ''
project_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
sandbox_test_folder = '/openhands/workspace'
sandbox_test_folder = '/workspace'
def _get_runtime_sid(runtime: Runtime) -> str:
@@ -233,9 +233,10 @@ def _load_runtime(
if use_workspace:
test_mount_path = os.path.join(config.workspace_base, 'rt')
elif temp_dir is not None:
test_mount_path = os.path.join(temp_dir, sid)
test_mount_path = temp_dir
else:
test_mount_path = None
config.workspace_base = test_mount_path
config.workspace_mount_path = test_mount_path
# Mounting folder specific for this test inside the sandbox

View File

@@ -210,7 +210,7 @@ done && echo "success"
def test_cmd_run(temp_dir, runtime_cls, run_as_openhands):
runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
obs = _run_cmd_action(runtime, 'ls -l /openhands/workspace')
obs = _run_cmd_action(runtime, 'ls -l /workspace')
assert obs.exit_code == 0
obs = _run_cmd_action(runtime, 'ls -l')
@@ -377,7 +377,7 @@ def test_copy_to_non_existent_directory(temp_dir, runtime_cls):
def test_overwrite_existing_file(temp_dir, runtime_cls):
runtime = _load_runtime(temp_dir, runtime_cls)
try:
sandbox_dir = '/openhands/workspace'
sandbox_dir = '/workspace'
obs = _run_cmd_action(runtime, f'ls -alh {sandbox_dir}')
assert obs.exit_code == 0

View File

@@ -52,7 +52,7 @@ def test_simple_cmd_ipython_and_fileop(temp_dir, runtime_cls, run_as_openhands):
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.content.strip() == (
'Hello, `World`!\n'
'[Jupyter current working directory: /openhands/workspace]\n'
'[Jupyter current working directory: /workspace]\n'
'[Jupyter Python interpreter: /openhands/poetry/openhands-ai-5O4_aCHf-py3.12/bin/python]'
)
@@ -73,7 +73,7 @@ def test_simple_cmd_ipython_and_fileop(temp_dir, runtime_cls, run_as_openhands):
assert obs.content == ''
# event stream runtime will always use absolute path
assert obs.path == '/openhands/workspace/hello.sh'
assert obs.path == '/workspace/hello.sh'
# Test read file (file should exist)
action_read = FileReadAction(path='hello.sh')
@@ -85,7 +85,7 @@ def test_simple_cmd_ipython_and_fileop(temp_dir, runtime_cls, run_as_openhands):
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.content == 'echo "Hello, World!"\n'
assert obs.path == '/openhands/workspace/hello.sh'
assert obs.path == '/workspace/hello.sh'
# clean up
action = CmdRunAction(command='rm -rf hello.sh')
@@ -188,7 +188,7 @@ def test_ipython_simple(temp_dir, runtime_cls):
obs.content.strip()
== (
'1\n'
'[Jupyter current working directory: /openhands/workspace]\n'
'[Jupyter current working directory: /workspace]\n'
'[Jupyter Python interpreter: /openhands/poetry/openhands-ai-5O4_aCHf-py3.12/bin/python]'
).strip()
)
@@ -224,7 +224,7 @@ def test_ipython_package_install(temp_dir, runtime_cls, run_as_openhands):
# import should not error out
assert obs.content.strip() == (
'[Code executed successfully with no output]\n'
'[Jupyter current working directory: /openhands/workspace]\n'
'[Jupyter current working directory: /workspace]\n'
'[Jupyter Python interpreter: /openhands/poetry/openhands-ai-5O4_aCHf-py3.12/bin/python]'
)
@@ -273,16 +273,16 @@ def test_ipython_file_editor_permissions_as_openhands(temp_dir, runtime_cls):
# Try to use file editor in openhands sandbox directory - should work
test_code = """
# Create file
print(file_editor(command='create', path='/openhands/workspace/test.txt', file_text='Line 1\\nLine 2\\nLine 3'))
print(file_editor(command='create', path='/workspace/test.txt', file_text='Line 1\\nLine 2\\nLine 3'))
# View file
print(file_editor(command='view', path='/openhands/workspace/test.txt'))
print(file_editor(command='view', path='/workspace/test.txt'))
# Edit file
print(file_editor(command='str_replace', path='/openhands/workspace/test.txt', old_str='Line 2', new_str='New Line 2'))
print(file_editor(command='str_replace', path='/workspace/test.txt', old_str='Line 2', new_str='New Line 2'))
# Undo edit
print(file_editor(command='undo_edit', path='/openhands/workspace/test.txt'))
print(file_editor(command='undo_edit', path='/workspace/test.txt'))
"""
action = IPythonRunCellAction(code=test_code)
logger.info(action, extra={'msg_type': 'ACTION'})
@@ -297,7 +297,7 @@ print(file_editor(command='undo_edit', path='/openhands/workspace/test.txt'))
assert 'undone successfully' in obs.content
# Clean up
action = CmdRunAction(command='rm -f /openhands/workspace/test.txt')
action = CmdRunAction(command='rm -f /workspace/test.txt')
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -314,7 +314,7 @@ print(file_editor(command='undo_edit', path='/openhands/workspace/test.txt'))
def test_file_read_and_edit_via_oh_aci(runtime_cls, run_as_openhands):
runtime = _load_runtime(None, runtime_cls, run_as_openhands)
sandbox_dir = '/openhands/workspace'
sandbox_dir = '/workspace'
actions = [
{

View File

@@ -0,0 +1,197 @@
"""Tests for microagent loading in runtime."""
from pathlib import Path
from conftest import (
_close_test_runtime,
_load_runtime,
)
from openhands.microagent import KnowledgeMicroAgent, RepoMicroAgent, TaskMicroAgent
def _create_test_microagents(test_dir: str):
"""Create test microagent files in the given directory."""
microagents_dir = Path(test_dir) / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create test knowledge agent
knowledge_dir = microagents_dir / 'knowledge'
knowledge_dir.mkdir(exist_ok=True)
knowledge_agent = """---
name: test_knowledge_agent
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- test
- pytest
---
# Test Guidelines
Testing best practices and guidelines.
"""
(knowledge_dir / 'knowledge.md').write_text(knowledge_agent)
# Create test repo agent
repo_agent = """---
name: test_repo_agent
type: repo
version: 1.0.0
agent: CodeActAgent
---
# Test Repository Agent
Repository-specific test instructions.
"""
(microagents_dir / 'repo.md').write_text(repo_agent)
# Create test task agent in a nested directory
task_dir = microagents_dir / 'tasks' / 'nested'
task_dir.mkdir(parents=True, exist_ok=True)
task_agent = """---
name: test_task
type: task
version: 1.0.0
agent: CodeActAgent
---
# Test Task
Test task content
"""
(task_dir / 'task.md').write_text(task_agent)
# Create legacy repo instructions
legacy_instructions = """# Legacy Instructions
These are legacy repository instructions.
"""
(Path(test_dir) / '.openhands_instructions').write_text(legacy_instructions)
def test_load_microagents_with_trailing_slashes(
temp_dir, runtime_cls, run_as_openhands
):
"""Test loading microagents when directory paths have trailing slashes."""
# Create test files
_create_test_microagents(temp_dir)
runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Load microagents
loaded_agents = runtime.get_microagents_from_selected_repo(None)
# Verify all agents are loaded
knowledge_agents = [
a for a in loaded_agents if isinstance(a, KnowledgeMicroAgent)
]
repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroAgent)]
task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroAgent)]
# Check knowledge agents
assert len(knowledge_agents) == 1
agent = knowledge_agents[0]
assert agent.name == 'test_knowledge_agent'
assert 'test' in agent.triggers
assert 'pytest' in agent.triggers
# Check repo agents (including legacy)
assert len(repo_agents) == 2 # repo.md + .openhands_instructions
repo_names = {a.name for a in repo_agents}
assert 'test_repo_agent' in repo_names
assert 'repo_legacy' in repo_names
# Check task agents
assert len(task_agents) == 1
agent = task_agents[0]
assert agent.name == 'test_task'
finally:
_close_test_runtime(runtime)
def test_load_microagents_with_selected_repo(temp_dir, runtime_cls, run_as_openhands):
"""Test loading microagents from a selected repository."""
# Create test files in a repository-like structure
repo_dir = Path(temp_dir) / 'OpenHands'
repo_dir.mkdir(parents=True)
_create_test_microagents(str(repo_dir))
runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Load microagents with selected repository
loaded_agents = runtime.get_microagents_from_selected_repo(
'All-Hands-AI/OpenHands'
)
# Verify all agents are loaded
knowledge_agents = [
a for a in loaded_agents if isinstance(a, KnowledgeMicroAgent)
]
repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroAgent)]
task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroAgent)]
# Check knowledge agents
assert len(knowledge_agents) == 1
agent = knowledge_agents[0]
assert agent.name == 'test_knowledge_agent'
assert 'test' in agent.triggers
assert 'pytest' in agent.triggers
# Check repo agents (including legacy)
assert len(repo_agents) == 2 # repo.md + .openhands_instructions
repo_names = {a.name for a in repo_agents}
assert 'test_repo_agent' in repo_names
assert 'repo_legacy' in repo_names
# Check task agents
assert len(task_agents) == 1
agent = task_agents[0]
assert agent.name == 'test_task'
finally:
_close_test_runtime(runtime)
def test_load_microagents_with_missing_files(temp_dir, runtime_cls, run_as_openhands):
"""Test loading microagents when some files are missing."""
# Create only repo.md, no other files
microagents_dir = Path(temp_dir) / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
repo_agent = """---
name: test_repo_agent
type: repo
version: 1.0.0
agent: CodeActAgent
---
# Test Repository Agent
Repository-specific test instructions.
"""
(microagents_dir / 'repo.md').write_text(repo_agent)
runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Load microagents
loaded_agents = runtime.get_microagents_from_selected_repo(None)
# Verify only repo agent is loaded
knowledge_agents = [
a for a in loaded_agents if isinstance(a, KnowledgeMicroAgent)
]
repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroAgent)]
task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroAgent)]
assert len(knowledge_agents) == 0
assert len(repo_agents) == 1
assert len(task_agents) == 0
agent = repo_agents[0]
assert agent.name == 'test_repo_agent'
finally:
_close_test_runtime(runtime)

View File

@@ -1,12 +1,10 @@
import pytest
from openhands.resolver.patching.apply import apply_diff
from openhands.resolver.patching.exceptions import HunkApplyException
from openhands.resolver.patching.patch import parse_diff, diffobj
from openhands.resolver.patching.patch import diffobj, parse_diff
def test_patch_apply_with_empty_lines():
# The original file has no indentation and uses \n line endings
original_content = "# PR Viewer\n\nThis React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.\n\n## Setup"
original_content = '# PR Viewer\n\nThis React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.\n\n## Setup'
# The patch has spaces at the start of each line and uses \n line endings
patch = """diff --git a/README.md b/README.md
@@ -19,18 +17,20 @@ index b760a53..5071727 100644
-This React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.
+This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization."""
print("Original content lines:")
print('Original content lines:')
for i, line in enumerate(original_content.splitlines(), 1):
print(f"{i}: {repr(line)}")
print(f'{i}: {repr(line)}')
print("\nPatch lines:")
print('\nPatch lines:')
for i, line in enumerate(patch.splitlines(), 1):
print(f"{i}: {repr(line)}")
print(f'{i}: {repr(line)}')
changes = parse_diff(patch)
print("\nParsed changes:")
print('\nParsed changes:')
for change in changes:
print(f"Change(old={change.old}, new={change.new}, line={repr(change.line)}, hunk={change.hunk})")
print(
f'Change(old={change.old}, new={change.new}, line={repr(change.line)}, hunk={change.hunk})'
)
diff = diffobj(header=None, changes=changes, text=patch)
# Apply the patch
@@ -38,10 +38,10 @@ index b760a53..5071727 100644
# The patch should be applied successfully
expected_result = [
"# PR Viewer",
"",
"This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.",
"",
"## Setup"
'# PR Viewer',
'',
'This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.',
'',
'## Setup',
]
assert result == expected_result
assert result == expected_result

View File

@@ -143,3 +143,65 @@ Invalid agent content
with pytest.raises(MicroAgentValidationError):
BaseMicroAgent.load(temp_microagents_dir / 'invalid.md')
def test_load_microagents_with_nested_dirs(temp_microagents_dir):
"""Test loading microagents from nested directories."""
# Create nested knowledge agent
nested_dir = temp_microagents_dir / 'nested' / 'dir'
nested_dir.mkdir(parents=True)
nested_agent = """---
name: nested_knowledge_agent
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- nested
---
# Nested Test Guidelines
Testing nested directory loading.
"""
(nested_dir / 'nested.md').write_text(nested_agent)
repo_agents, knowledge_agents, task_agents = load_microagents_from_dir(
temp_microagents_dir
)
# Check that we can find the nested agent
assert len(knowledge_agents) == 2 # Original + nested
agent = knowledge_agents['nested_knowledge_agent']
assert isinstance(agent, KnowledgeMicroAgent)
assert 'nested' in agent.triggers
def test_load_microagents_with_trailing_slashes(temp_microagents_dir):
"""Test loading microagents when directory paths have trailing slashes."""
# Create a directory with trailing slash
knowledge_dir = temp_microagents_dir / 'knowledge/'
knowledge_dir.mkdir(exist_ok=True)
knowledge_agent = """---
name: trailing_knowledge_agent
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- trailing
---
# Trailing Slash Test
Testing loading with trailing slashes.
"""
(knowledge_dir / 'trailing.md').write_text(knowledge_agent)
repo_agents, knowledge_agents, task_agents = load_microagents_from_dir(
str(temp_microagents_dir) + '/' # Add trailing slash to test
)
# Check that we can find the agent despite trailing slashes
assert len(knowledge_agents) == 2 # Original + trailing
agent = knowledge_agents['trailing_knowledge_agent']
assert isinstance(agent, KnowledgeMicroAgent)
assert 'trailing' in agent.triggers