feat(evolution): Phase A - lifecycle hooks + EvolutionConfig

U11: EvolutionMixin integrated into ConfigDrivenAgent lifecycle
  - on_task_complete triggers evolve_after_task
  - on_task_failed records failure patterns
  - Evolution errors never break main task flow

U12: EvolutionConfig added to SkillConfig
  - enabled, reflect_on_failure, auto_apply, min_quality_threshold
  - Backward compatible: defaults to enabled=False

21 new tests passing, no regression.
This commit is contained in:
chiguyong 2026-06-06 12:05:56 +08:00
parent 2844eeb548
commit acec8ff743
3 changed files with 450 additions and 1 deletions

View File

@ -16,6 +16,8 @@ import yaml
from agentkit.core.base import BaseAgent from agentkit.core.base import BaseAgent
from agentkit.core.exceptions import ConfigValidationError from agentkit.core.exceptions import ConfigValidationError
from agentkit.core.protocol import AgentCapability, TaskMessage from agentkit.core.protocol import AgentCapability, TaskMessage
from agentkit.evolution.lifecycle import EvolutionMixin
from agentkit.evolution.reflector import Reflector
from agentkit.prompts.section import PromptSection from agentkit.prompts.section import PromptSection
from agentkit.prompts.template import PromptTemplate from agentkit.prompts.template import PromptTemplate
from agentkit.tools.base import Tool from agentkit.tools.base import Tool
@ -153,7 +155,7 @@ class AgentConfig:
return d return d
class ConfigDrivenAgent(BaseAgent): class ConfigDrivenAgent(BaseAgent, EvolutionMixin):
"""配置驱动的 Agent """配置驱动的 Agent
YAML/Dict 配置自动组装支持三种任务模式 YAML/Dict 配置自动组装支持三种任务模式
@ -247,6 +249,28 @@ class ConfigDrivenAgent(BaseAgent):
from agentkit.quality.gate import QualityGate from agentkit.quality.gate import QualityGate
self._quality_gate = QualityGate() self._quality_gate = QualityGate()
# v2: Initialize Evolution if configured
evolution_config = getattr(config, 'evolution', None)
if evolution_config is not None:
# Support both dict and EvolutionConfig
if isinstance(evolution_config, dict):
is_enabled = evolution_config.get("enabled", False)
else:
is_enabled = getattr(evolution_config, 'enabled', False)
else:
is_enabled = False
if is_enabled:
reflector = Reflector()
EvolutionMixin.__init__(
self,
reflector=reflector,
)
self._evolution_enabled = True
else:
EvolutionMixin.__init__(self) # Initialize with no components
self._evolution_enabled = False
# v2: Initialize Output Standardizer # v2: Initialize Output Standardizer
from agentkit.quality.output import OutputStandardizer from agentkit.quality.output import OutputStandardizer
self._output_standardizer = OutputStandardizer() self._output_standardizer = OutputStandardizer()
@ -278,6 +302,44 @@ class ConfigDrivenAgent(BaseAgent):
def prompt_template(self) -> PromptTemplate | None: def prompt_template(self) -> PromptTemplate | None:
return self._prompt_template return self._prompt_template
async def on_task_complete(self, task: TaskMessage, output: dict) -> None:
"""Task complete hook - trigger evolution if enabled"""
if self._evolution_enabled:
try:
from agentkit.core.protocol import TaskResult, TaskStatus
from datetime import datetime, timezone
result = TaskResult(
task_id=task.task_id,
agent_name=self.name,
status=TaskStatus.COMPLETED,
output_data=output,
error_message=None,
started_at=datetime.now(timezone.utc),
completed_at=datetime.now(timezone.utc),
)
await self.evolve_after_task(task, result)
except Exception as e:
logger.warning(f"Evolution after task failed: {e}")
async def on_task_failed(self, task: TaskMessage, error: Exception) -> None:
"""Task failed hook - record failure for evolution"""
if self._evolution_enabled:
try:
from agentkit.core.protocol import TaskResult, TaskStatus
from datetime import datetime, timezone
result = TaskResult(
task_id=task.task_id,
agent_name=self.name,
status=TaskStatus.FAILED,
output_data=None,
error_message=str(error),
started_at=datetime.now(timezone.utc),
completed_at=datetime.now(timezone.utc),
)
await self.evolve_after_task(task, result)
except Exception as e:
logger.warning(f"Evolution after task failure failed: {e}")
def _bind_tools(self) -> None: def _bind_tools(self) -> None:
"""根据配置绑定工具""" """根据配置绑定工具"""
for tool_name in self._config.tools: for tool_name in self._config.tools:

View File

@ -11,6 +11,16 @@ from agentkit.tools.base import Tool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass
class EvolutionConfig:
"""Evolution configuration"""
enabled: bool = False
reflect_on_failure: bool = True # Whether to reflect on failed tasks
auto_apply: bool = False # Whether to auto-apply optimizations (without AB test)
min_quality_threshold: float = 0.5 # Minimum quality score to trigger optimization
@dataclass @dataclass
class IntentConfig: class IntentConfig:
"""意图配置""" """意图配置"""
@ -59,6 +69,7 @@ class SkillConfig(AgentConfig):
quality_gate: dict[str, Any] | None = None, quality_gate: dict[str, Any] | None = None,
execution_mode: str = "react", execution_mode: str = "react",
max_steps: int = 5, max_steps: int = 5,
evolution: dict[str, Any] | None = None,
): ):
super().__init__( super().__init__(
name=name, name=name,
@ -80,6 +91,7 @@ class SkillConfig(AgentConfig):
self.quality_gate = QualityGateConfig(**(quality_gate or {})) self.quality_gate = QualityGateConfig(**(quality_gate or {}))
self.execution_mode = execution_mode self.execution_mode = execution_mode
self.max_steps = max_steps self.max_steps = max_steps
self.evolution = EvolutionConfig(**(evolution or {}))
self._validate_v2() self._validate_v2()
def _validate_v2(self) -> None: def _validate_v2(self) -> None:
@ -116,6 +128,7 @@ class SkillConfig(AgentConfig):
quality_gate=data.get("quality_gate"), quality_gate=data.get("quality_gate"),
execution_mode=data.get("execution_mode", "react"), execution_mode=data.get("execution_mode", "react"),
max_steps=data.get("max_steps", 5), max_steps=data.get("max_steps", 5),
evolution=data.get("evolution"),
) )
@classmethod @classmethod
@ -149,6 +162,12 @@ class SkillConfig(AgentConfig):
} }
d["execution_mode"] = self.execution_mode d["execution_mode"] = self.execution_mode
d["max_steps"] = self.max_steps d["max_steps"] = self.max_steps
d["evolution"] = {
"enabled": self.evolution.enabled,
"reflect_on_failure": self.evolution.reflect_on_failure,
"auto_apply": self.evolution.auto_apply,
"min_quality_threshold": self.evolution.min_quality_threshold,
}
return d return d

View File

@ -0,0 +1,368 @@
"""U11+U12 测试: Evolution 生命周期集成 + EvolutionConfig
覆盖
- EvolutionConfig 默认值与自定义值
- SkillConfig evolution 字段
- ConfigDrivenAgent 集成 EvolutionMixin
- 生命周期钩子触发进化
- 进化失败不影响主任务流程
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agentkit.core.protocol import TaskMessage, TaskResult, TaskStatus
# ── Helpers ──────────────────────────────────────────────
def _make_task(**overrides) -> TaskMessage:
defaults = dict(
task_id="test-task-001",
agent_name="test_agent",
task_type="generate",
priority=1,
input_data={"query": "hello"},
callback_url=None,
created_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
return TaskMessage(**defaults)
def _make_task_result(**overrides) -> TaskResult:
defaults = dict(
task_id="test-task-001",
agent_name="test_agent",
status=TaskStatus.COMPLETED,
output_data={"result": "ok"},
error_message=None,
started_at=datetime.now(timezone.utc),
completed_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
return TaskResult(**defaults)
# ── EvolutionConfig 测试 ──────────────────────────────────
class TestEvolutionConfig:
"""U12: EvolutionConfig 数据类测试"""
def test_default_values(self):
"""默认 EvolutionConfig — enabled=False"""
from agentkit.skills.base import EvolutionConfig
config = EvolutionConfig()
assert config.enabled is False
assert config.reflect_on_failure is True
assert config.auto_apply is False
assert config.min_quality_threshold == 0.5
def test_from_dict_all_fields(self):
"""EvolutionConfig 从字典创建 — 所有字段设置"""
from agentkit.skills.base import EvolutionConfig
config = EvolutionConfig(
enabled=True,
reflect_on_failure=False,
auto_apply=True,
min_quality_threshold=0.8,
)
assert config.enabled is True
assert config.reflect_on_failure is False
assert config.auto_apply is True
assert config.min_quality_threshold == 0.8
def test_from_dict_partial(self):
"""EvolutionConfig 部分字段 — 缺失字段使用默认值"""
from agentkit.skills.base import EvolutionConfig
config = EvolutionConfig(enabled=True)
assert config.enabled is True
assert config.reflect_on_failure is True # default
assert config.auto_apply is False # default
assert config.min_quality_threshold == 0.5 # default
# ── SkillConfig evolution 字段测试 ─────────────────────────
class TestSkillConfigEvolution:
"""U12: SkillConfig 的 evolution 字段"""
def test_skill_config_without_evolution(self):
"""SkillConfig 无 evolution — 默认 enabled=False"""
from agentkit.skills.base import SkillConfig
config = SkillConfig(
name="test_agent",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test", "instructions": "test"},
)
assert config.evolution.enabled is False
def test_skill_config_with_evolution(self):
"""SkillConfig 有 evolution 配置 — 正确解析"""
from agentkit.skills.base import SkillConfig
config = SkillConfig(
name="test_agent",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test", "instructions": "test"},
evolution={"enabled": True, "auto_apply": True, "min_quality_threshold": 0.7},
)
assert config.evolution.enabled is True
assert config.evolution.auto_apply is True
assert config.evolution.min_quality_threshold == 0.7
def test_skill_config_to_dict_includes_evolution(self):
"""SkillConfig.to_dict 包含 evolution 字段"""
from agentkit.skills.base import SkillConfig
config = SkillConfig(
name="test_agent",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test", "instructions": "test"},
evolution={"enabled": True},
)
d = config.to_dict()
assert "evolution" in d
assert d["evolution"]["enabled"] is True
assert d["evolution"]["reflect_on_failure"] is True
assert d["evolution"]["auto_apply"] is False
assert d["evolution"]["min_quality_threshold"] == 0.5
def test_skill_config_from_dict_with_evolution(self):
"""SkillConfig.from_dict 正确解析 evolution"""
from agentkit.skills.base import SkillConfig
data = {
"name": "test_agent",
"agent_type": "test",
"task_mode": "llm_generate",
"prompt": {"identity": "test", "instructions": "test"},
"evolution": {"enabled": True, "reflect_on_failure": False},
}
config = SkillConfig.from_dict(data)
assert config.evolution.enabled is True
assert config.evolution.reflect_on_failure is False
# ── ConfigDrivenAgent evolution 集成测试 ──────────────────
class TestConfigDrivenAgentEvolution:
"""U11: ConfigDrivenAgent 集成 EvolutionMixin"""
def _make_agent_config(self, evolution=None):
from agentkit.core.config_driven import AgentConfig
config = AgentConfig(
name="test_agent",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test", "instructions": "test"},
)
if evolution is not None:
config.evolution = evolution
return config
def _make_skill_config(self, evolution=None):
from agentkit.skills.base import SkillConfig
return SkillConfig(
name="test_agent",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test", "instructions": "test"},
evolution=evolution,
)
def test_agent_without_evolution_config(self):
"""Agent 无 evolution 配置 — _evolution_enabled=False"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_agent_config()
agent = ConfigDrivenAgent(config=config)
assert agent._evolution_enabled is False
def test_agent_with_evolution_enabled(self):
"""Agent 有 evolution 且 enabled=True — _evolution_enabled=True"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_agent_config(evolution={"enabled": True})
agent = ConfigDrivenAgent(config=config)
assert agent._evolution_enabled is True
def test_agent_with_evolution_disabled(self):
"""Agent 有 evolution 但 enabled=False — _evolution_enabled=False"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_agent_config(evolution={"enabled": False})
agent = ConfigDrivenAgent(config=config)
assert agent._evolution_enabled is False
async def test_on_task_complete_evolution_disabled(self):
"""on_task_complete 进化禁用 — 不调用 evolve_after_task"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_agent_config()
agent = ConfigDrivenAgent(config=config)
task = _make_task()
output = {"result": "ok"}
# Should not raise and should not call evolve_after_task
await agent.on_task_complete(task, output)
async def test_on_task_complete_evolution_enabled(self):
"""on_task_complete 进化启用 — 调用 evolve_after_task"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_agent_config(evolution={"enabled": True})
agent = ConfigDrivenAgent(config=config)
task = _make_task()
output = {"result": "ok"}
with patch.object(agent, "evolve_after_task", new_callable=AsyncMock) as mock_evolve:
await agent.on_task_complete(task, output)
mock_evolve.assert_called_once()
# Verify the TaskResult passed to evolve_after_task
call_args = mock_evolve.call_args
result_arg = call_args[0][1] # second positional arg is TaskResult
assert result_arg.status == TaskStatus.COMPLETED
assert result_arg.output_data == output
async def test_on_task_failed_evolution_enabled(self):
"""on_task_failed 进化启用 — 调用 evolve_after_task"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_agent_config(evolution={"enabled": True})
agent = ConfigDrivenAgent(config=config)
task = _make_task()
error = ValueError("test error")
with patch.object(agent, "evolve_after_task", new_callable=AsyncMock) as mock_evolve:
await agent.on_task_failed(task, error)
mock_evolve.assert_called_once()
# Verify the TaskResult passed to evolve_after_task
call_args = mock_evolve.call_args
result_arg = call_args[0][1] # second positional arg is TaskResult
assert result_arg.status == TaskStatus.FAILED
assert result_arg.error_message == "test error"
async def test_evolution_failure_does_not_break_task(self):
"""进化失败不影响任务完成"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_agent_config(evolution={"enabled": True})
agent = ConfigDrivenAgent(config=config)
task = _make_task()
output = {"result": "ok"}
with patch.object(agent, "evolve_after_task", new_callable=AsyncMock, side_effect=RuntimeError("evolution crashed")):
# Should NOT raise — evolution failure is caught
await agent.on_task_complete(task, output)
async def test_evolution_failure_on_task_failed_does_not_break(self):
"""进化失败不影响 on_task_failed"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_agent_config(evolution={"enabled": True})
agent = ConfigDrivenAgent(config=config)
task = _make_task()
error = ValueError("task error")
with patch.object(agent, "evolve_after_task", new_callable=AsyncMock, side_effect=RuntimeError("evolution crashed")):
# Should NOT raise
await agent.on_task_failed(task, error)
def test_skill_config_evolution_propagated(self):
"""SkillConfig 的 evolution 配置传递到 ConfigDrivenAgent"""
from agentkit.core.config_driven import ConfigDrivenAgent
config = self._make_skill_config(evolution={"enabled": True})
agent = ConfigDrivenAgent(config=config)
assert agent._evolution_enabled is True
# ── EvolutionMixin 集成测试 ───────────────────────────────
class TestEvolutionMixinIntegration:
"""U11: EvolutionMixin 方法集成到 ConfigDrivenAgent"""
def _make_agent_with_evolution(self):
from agentkit.core.config_driven import AgentConfig, ConfigDrivenAgent
config = AgentConfig(
name="test_agent",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test", "instructions": "test"},
)
config.evolution = {"enabled": True}
return ConfigDrivenAgent(config=config)
def test_agent_has_get_evolution_history(self):
"""Agent 继承 get_evolution_history 方法"""
from agentkit.core.config_driven import ConfigDrivenAgent
from agentkit.evolution.lifecycle import EvolutionMixin
agent = self._make_agent_with_evolution()
assert hasattr(agent, "get_evolution_history")
assert callable(agent.get_evolution_history)
def test_agent_has_set_current_module(self):
"""Agent 继承 set_current_module 方法"""
from agentkit.core.config_driven import ConfigDrivenAgent
from agentkit.evolution.lifecycle import EvolutionMixin
agent = self._make_agent_with_evolution()
assert hasattr(agent, "set_current_module")
assert callable(agent.set_current_module)
def test_get_evolution_history_empty_initially(self):
"""get_evolution_history 初始返回空列表"""
agent = self._make_agent_with_evolution()
history = agent.get_evolution_history()
assert history == []
def test_set_current_module_works(self):
"""set_current_module 正常工作"""
from agentkit.evolution.prompt_optimizer import Module, Signature
agent = self._make_agent_with_evolution()
signature = Signature(
input_fields={"query": "user query"},
output_fields={"result": "result"},
instruction="test instructions",
)
module = Module(name="test_module", signature=signature)
agent.set_current_module(module)
assert agent._current_module is not None
assert agent._current_module.name == "test_module"
def test_mro_correct(self):
"""MRO 正确: ConfigDrivenAgent → BaseAgent → EvolutionMixin"""
from agentkit.core.config_driven import ConfigDrivenAgent
from agentkit.core.base import BaseAgent
from agentkit.evolution.lifecycle import EvolutionMixin
mro = ConfigDrivenAgent.__mro__
# BaseAgent should come before EvolutionMixin in MRO
base_idx = mro.index(BaseAgent)
mixin_idx = mro.index(EvolutionMixin)
assert base_idx < mixin_idx, f"BaseAgent (idx={base_idx}) should come before EvolutionMixin (idx={mixin_idx}) in MRO"