opik-optimizer 1.1.0__py3-none-any.whl → 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. opik_optimizer/__init__.py +2 -0
  2. opik_optimizer/base_optimizer.py +376 -19
  3. opik_optimizer/evolutionary_optimizer/evaluation_ops.py +80 -17
  4. opik_optimizer/evolutionary_optimizer/evolutionary_optimizer.py +179 -39
  5. opik_optimizer/evolutionary_optimizer/llm_support.py +3 -1
  6. opik_optimizer/evolutionary_optimizer/mcp.py +249 -0
  7. opik_optimizer/evolutionary_optimizer/mutation_ops.py +17 -3
  8. opik_optimizer/evolutionary_optimizer/population_ops.py +5 -0
  9. opik_optimizer/evolutionary_optimizer/prompts.py +47 -0
  10. opik_optimizer/evolutionary_optimizer/reporting.py +12 -0
  11. opik_optimizer/few_shot_bayesian_optimizer/few_shot_bayesian_optimizer.py +65 -59
  12. opik_optimizer/gepa_optimizer/adapter.py +5 -3
  13. opik_optimizer/gepa_optimizer/gepa_optimizer.py +163 -66
  14. opik_optimizer/mcp_utils/mcp_workflow.py +57 -3
  15. opik_optimizer/meta_prompt_optimizer/meta_prompt_optimizer.py +75 -69
  16. opik_optimizer/mipro_optimizer/_lm.py +10 -3
  17. opik_optimizer/mipro_optimizer/_mipro_optimizer_v2.py +1 -1
  18. opik_optimizer/mipro_optimizer/mipro_optimizer.py +96 -21
  19. opik_optimizer/optimizable_agent.py +5 -0
  20. opik_optimizer/optimization_result.py +1 -0
  21. opik_optimizer/utils/core.py +56 -14
  22. {opik_optimizer-1.1.0.dist-info → opik_optimizer-2.0.1.dist-info}/METADATA +97 -10
  23. {opik_optimizer-1.1.0.dist-info → opik_optimizer-2.0.1.dist-info}/RECORD +27 -26
  24. /opik_optimizer/{colbert.py → utils/colbert.py} +0 -0
  25. {opik_optimizer-1.1.0.dist-info → opik_optimizer-2.0.1.dist-info}/WHEEL +0 -0
  26. {opik_optimizer-1.1.0.dist-info → opik_optimizer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  27. {opik_optimizer-1.1.0.dist-info → opik_optimizer-2.0.1.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  import json
2
3
  import logging
3
4
  import random
@@ -13,15 +14,22 @@ import opik
13
14
  # DEAP imports
14
15
  from deap import base, tools
15
16
  from deap import creator as _creator
16
- from opik.api_objects import opik_client, optimization
17
+ from opik.api_objects import optimization
17
18
  from opik.environment import get_tqdm_for_current_environment
18
19
 
19
20
  from opik_optimizer.base_optimizer import BaseOptimizer, OptimizationRound
20
21
  from opik_optimizer.optimization_config import chat_prompt
21
22
  from opik_optimizer.optimization_result import OptimizationResult
22
23
  from opik_optimizer.optimizable_agent import OptimizableAgent
24
+ from opik_optimizer.mcp_utils.mcp_second_pass import MCPSecondPassCoordinator
25
+ from opik_optimizer.mcp_utils.mcp_workflow import (
26
+ MCPExecutionConfig,
27
+ extract_tool_arguments,
28
+ )
29
+ from opik_optimizer.utils.prompt_segments import extract_prompt_segments
30
+
31
+ from .mcp import EvolutionaryMCPContext, finalize_mcp_result
23
32
 
24
- from .. import utils
25
33
  from . import reporting
26
34
  from .llm_support import LlmSupport
27
35
  from .mutation_ops import MutationOps
@@ -135,8 +143,11 @@ class EvolutionaryOptimizer(BaseOptimizer):
135
143
  RuntimeWarning,
136
144
  )
137
145
  if "project_name" in model_kwargs:
138
- print(
139
- "Removing `project_name` from constructor; it now belongs in the ChatPrompt()"
146
+ warnings.warn(
147
+ "The 'project_name' parameter in optimizer constructor is deprecated. "
148
+ "Set project_name in the ChatPrompt instead.",
149
+ DeprecationWarning,
150
+ stacklevel=2,
140
151
  )
141
152
  del model_kwargs["project_name"]
142
153
 
@@ -147,22 +158,25 @@ class EvolutionaryOptimizer(BaseOptimizer):
147
158
  self.crossover_rate = crossover_rate
148
159
  self.tournament_size = tournament_size
149
160
  if num_threads is not None:
150
- print("num_threads is deprecated; use n_threads instead")
161
+ warnings.warn(
162
+ "The 'num_threads' parameter is deprecated and will be removed in a future version. "
163
+ "Use 'n_threads' instead.",
164
+ DeprecationWarning,
165
+ stacklevel=2,
166
+ )
151
167
  n_threads = num_threads
152
168
  self.num_threads = n_threads
153
169
  self.elitism_size = elitism_size
154
170
  self.adaptive_mutation = adaptive_mutation
155
171
  self.enable_moo = enable_moo
156
172
  self.enable_llm_crossover = enable_llm_crossover
157
- self.seed = seed
173
+ self.seed = seed if seed is not None else self.DEFAULT_SEED
158
174
  self.output_style_guidance = (
159
175
  output_style_guidance
160
176
  if output_style_guidance is not None
161
177
  else self.DEFAULT_OUTPUT_STYLE_GUIDANCE
162
178
  )
163
179
  self.infer_output_style = infer_output_style
164
- self.llm_call_counter = 0
165
- self._opik_client = opik_client.get_client_cached()
166
180
  self._current_optimization_id: str | None = None
167
181
  self._current_generation = 0
168
182
  self._best_fitness_history: list[float] = []
@@ -202,13 +216,6 @@ class EvolutionaryOptimizer(BaseOptimizer):
202
216
  # Attach methods from helper mixin modules to this instance to avoid
203
217
  # multiple inheritance while preserving behavior.
204
218
  self._attach_helper_methods()
205
- self.toolbox.register(
206
- "default_individual", lambda: creator.Individual("placeholder")
207
- )
208
- self.toolbox.register(
209
- "population", tools.initRepeat, list, self.toolbox.default_individual
210
- )
211
-
212
219
  if self.enable_llm_crossover:
213
220
  self.toolbox.register("mate", self._llm_deap_crossover)
214
221
  else:
@@ -232,6 +239,7 @@ class EvolutionaryOptimizer(BaseOptimizer):
232
239
  )
233
240
 
234
241
  # (methods already attached above)
242
+ self._mcp_context: EvolutionaryMCPContext | None = None
235
243
 
236
244
  def _attach_helper_methods(self) -> None:
237
245
  """Bind selected methods from mixin modules onto this instance."""
@@ -290,6 +298,35 @@ class EvolutionaryOptimizer(BaseOptimizer):
290
298
  # Style inference
291
299
  bind(StyleOps, ["_infer_output_style_from_dataset"])
292
300
 
301
+ def get_optimizer_metadata(self) -> dict[str, Any]:
302
+ return {
303
+ "population_size": self.population_size,
304
+ "num_generations": self.num_generations,
305
+ "mutation_rate": self.mutation_rate,
306
+ "crossover_rate": self.crossover_rate,
307
+ "tournament_size": self.tournament_size,
308
+ "elitism_size": self.elitism_size,
309
+ "adaptive_mutation": self.adaptive_mutation,
310
+ "enable_moo": self.enable_moo,
311
+ "enable_llm_crossover": self.enable_llm_crossover,
312
+ "infer_output_style": self.infer_output_style,
313
+ "output_style_guidance": self.output_style_guidance,
314
+ }
315
+
316
+ def _create_individual_from_prompt(
317
+ self, prompt_candidate: chat_prompt.ChatPrompt
318
+ ) -> Any:
319
+ individual = creator.Individual(prompt_candidate.get_messages())
320
+ setattr(individual, "tools", copy.deepcopy(prompt_candidate.tools))
321
+ return individual
322
+
323
+ def _update_individual_with_prompt(
324
+ self, individual: Any, prompt_candidate: chat_prompt.ChatPrompt
325
+ ) -> Any:
326
+ individual[:] = prompt_candidate.get_messages()
327
+ setattr(individual, "tools", copy.deepcopy(prompt_candidate.tools))
328
+ return individual
329
+
293
330
  def _get_adaptive_mutation_rate(self) -> float:
294
331
  """Calculate adaptive mutation rate based on population diversity and progress."""
295
332
  if not self.adaptive_mutation or len(self._best_fitness_history) < 2:
@@ -387,7 +424,7 @@ class EvolutionaryOptimizer(BaseOptimizer):
387
424
  )
388
425
 
389
426
  prompt_variants = self._initialize_population(seed_prompt)
390
- new_pop = [creator.Individual(p.get_messages()) for p in prompt_variants]
427
+ new_pop = [self._create_individual_from_prompt(p) for p in prompt_variants]
391
428
 
392
429
  for ind, fit in zip(new_pop, map(self.toolbox.evaluate, new_pop)):
393
430
  ind.fitness.values = fit
@@ -495,35 +532,27 @@ class EvolutionaryOptimizer(BaseOptimizer):
495
532
  experiment_config: Optional experiment configuration
496
533
  n_samples: Optional number of samples to use
497
534
  auto_continue: Whether to automatically continue optimization
498
- **kwargs: Additional keyword arguments
535
+ agent_class: Optional agent class to use
536
+ **kwargs: Additional keyword arguments including:
537
+ mcp_config (MCPExecutionConfig | None): MCP tool calling configuration (default: None)
499
538
  """
500
- if not isinstance(prompt, chat_prompt.ChatPrompt):
501
- raise ValueError("Prompt must be a ChatPrompt object")
539
+ # Use base class validation and setup methods
540
+ self.validate_optimization_inputs(prompt, dataset, metric)
541
+ self.configure_prompt_model(prompt)
542
+ self.agent_class = self.setup_agent_class(prompt, agent_class)
502
543
 
503
- if not isinstance(dataset, opik.Dataset):
504
- raise ValueError("Dataset must be a Dataset object")
505
-
506
- if not callable(metric):
507
- raise ValueError(
508
- "Metric must be a function that takes `dataset_item` and `llm_output` as arguments."
509
- )
510
-
511
- if prompt.model is None:
512
- prompt.model = self.model
513
- if prompt.model_kwargs is None:
514
- prompt.model_kwargs = self.model_kwargs
515
-
516
- if agent_class is None:
517
- self.agent_class = utils.create_litellm_agent_class(prompt)
518
- else:
519
- self.agent_class = agent_class
544
+ # Extract MCP config from kwargs (for optional MCP workflows)
545
+ mcp_config = kwargs.pop("mcp_config", None)
546
+ evaluation_kwargs: dict[str, Any] = {}
547
+ if mcp_config is not None:
548
+ evaluation_kwargs["mcp_config"] = mcp_config
520
549
 
521
550
  self.project_name = self.agent_class.project_name
522
551
 
523
552
  # Step 0. Start Opik optimization run
524
553
  opik_optimization_run: optimization.Optimization | None = None
525
554
  try:
526
- opik_optimization_run = self._opik_client.create_optimization(
555
+ opik_optimization_run = self.opik_client.create_optimization(
527
556
  dataset_name=dataset.name,
528
557
  objective_name=metric.__name__,
529
558
  metadata={"optimizer": self.__class__.__name__},
@@ -554,7 +583,7 @@ class EvolutionaryOptimizer(BaseOptimizer):
554
583
  )
555
584
 
556
585
  # Step 1. Step variables and define fitness function
557
- self.llm_call_counter = 0
586
+ self.reset_counters() # Reset counters for run
558
587
  self._history: list[OptimizationRound] = []
559
588
  self._current_generation = 0
560
589
  self._best_fitness_history = []
@@ -576,6 +605,7 @@ class EvolutionaryOptimizer(BaseOptimizer):
576
605
  experiment_config=(experiment_config or {}).copy(),
577
606
  optimization_id=self._current_optimization_id,
578
607
  verbose=0,
608
+ **evaluation_kwargs,
579
609
  )
580
610
  prompt_length = float(len(str(json.dumps(messages))))
581
611
  return (primary_fitness_score, prompt_length)
@@ -594,6 +624,7 @@ class EvolutionaryOptimizer(BaseOptimizer):
594
624
  experiment_config=(experiment_config or {}).copy(),
595
625
  optimization_id=self._current_optimization_id,
596
626
  verbose=0,
627
+ **evaluation_kwargs,
597
628
  )
598
629
  return (fitness_score, 0.0)
599
630
 
@@ -646,7 +677,7 @@ class EvolutionaryOptimizer(BaseOptimizer):
646
677
  )
647
678
 
648
679
  deap_population = [
649
- creator.Individual(p.get_messages()) for p in initial_prompts
680
+ self._create_individual_from_prompt(p) for p in initial_prompts
650
681
  ]
651
682
  deap_population = deap_population[: self.population_size]
652
683
 
@@ -939,6 +970,19 @@ class EvolutionaryOptimizer(BaseOptimizer):
939
970
  verbose=self.verbose,
940
971
  tools=getattr(final_best_prompt, "tools", None),
941
972
  )
973
+
974
+ final_tools = getattr(final_best_prompt, "tools", None)
975
+ if final_tools:
976
+ final_details["final_tools"] = final_tools
977
+ tool_prompts = {
978
+ (tool.get("function", {}).get("name") or f"tool_{idx}"): tool.get(
979
+ "function", {}
980
+ ).get("description")
981
+ for idx, tool in enumerate(final_tools)
982
+ }
983
+ else:
984
+ tool_prompts = None
985
+
942
986
  return OptimizationResult(
943
987
  optimizer=self.__class__.__name__,
944
988
  prompt=final_best_prompt.get_messages(),
@@ -949,10 +993,106 @@ class EvolutionaryOptimizer(BaseOptimizer):
949
993
  details=final_details,
950
994
  history=[x.model_dump() for x in self.get_history()],
951
995
  llm_calls=self.llm_call_counter,
996
+ tool_calls=self.tool_call_counter,
952
997
  dataset_id=dataset.id,
953
998
  optimization_id=self._current_optimization_id,
999
+ tool_prompts=tool_prompts,
954
1000
  )
955
1001
 
1002
+ def optimize_mcp(
1003
+ self,
1004
+ prompt: chat_prompt.ChatPrompt,
1005
+ dataset: opik.Dataset,
1006
+ metric: Callable,
1007
+ *,
1008
+ tool_name: str,
1009
+ second_pass: MCPSecondPassCoordinator,
1010
+ experiment_config: dict | None = None,
1011
+ n_samples: int | None = None,
1012
+ auto_continue: bool = False,
1013
+ agent_class: type[OptimizableAgent] | None = None,
1014
+ fallback_invoker: Callable[[dict[str, Any]], str] | None = None,
1015
+ fallback_arguments: Callable[[Any], dict[str, Any]] | None = None,
1016
+ allow_tool_use_on_second_pass: bool = False,
1017
+ **kwargs: Any,
1018
+ ) -> OptimizationResult:
1019
+ if prompt.tools is None or not prompt.tools:
1020
+ raise ValueError("Prompt must include tools for MCP optimization")
1021
+
1022
+ panel_style = kwargs.pop("tool_panel_style", "bright_magenta")
1023
+
1024
+ segments = extract_prompt_segments(prompt)
1025
+ tool_segment_id = f"tool:{tool_name}"
1026
+ segment_lookup = {segment.segment_id: segment for segment in segments}
1027
+ if tool_segment_id not in segment_lookup:
1028
+ raise ValueError(f"Tool '{tool_name}' not present in prompt tools")
1029
+
1030
+ fallback_args_fn = fallback_arguments or extract_tool_arguments
1031
+
1032
+ if fallback_invoker is None:
1033
+ function_map = getattr(prompt, "function_map", {}) or {}
1034
+ default_invoker_candidate = function_map.get(tool_name)
1035
+ if default_invoker_candidate is not None:
1036
+ typed_invoker = cast(Callable[..., str], default_invoker_candidate)
1037
+
1038
+ def _fallback_invoker(args: dict[str, Any]) -> str:
1039
+ return typed_invoker(**args)
1040
+
1041
+ fallback_invoker = _fallback_invoker
1042
+
1043
+ tool_entry = None
1044
+ for entry in prompt.tools or []:
1045
+ function = entry.get("function", {})
1046
+ if (function.get("name") or entry.get("name")) == tool_name:
1047
+ tool_entry = entry
1048
+ break
1049
+ if tool_entry is None:
1050
+ raise ValueError(f"Tool '{tool_name}' not present in prompt.tools")
1051
+
1052
+ original_description = tool_entry.get("function", {}).get("description", "")
1053
+ tool_metadata = segment_lookup[tool_segment_id].metadata.get("raw_tool", {})
1054
+
1055
+ mcp_config = MCPExecutionConfig(
1056
+ coordinator=second_pass,
1057
+ tool_name=tool_name,
1058
+ fallback_arguments=fallback_args_fn,
1059
+ fallback_invoker=fallback_invoker,
1060
+ allow_tool_use_on_second_pass=allow_tool_use_on_second_pass,
1061
+ )
1062
+
1063
+ previous_context = getattr(self, "_mcp_context", None)
1064
+ previous_crossover = self.enable_llm_crossover
1065
+
1066
+ context = EvolutionaryMCPContext(
1067
+ tool_name=tool_name,
1068
+ tool_segment_id=tool_segment_id,
1069
+ original_description=original_description,
1070
+ tool_metadata=tool_metadata,
1071
+ panel_style=panel_style,
1072
+ )
1073
+
1074
+ self._mcp_context = context
1075
+ self.enable_llm_crossover = False
1076
+
1077
+ try:
1078
+ result = self.optimize_prompt(
1079
+ prompt=prompt,
1080
+ dataset=dataset,
1081
+ metric=metric,
1082
+ experiment_config=experiment_config,
1083
+ n_samples=n_samples,
1084
+ auto_continue=auto_continue,
1085
+ agent_class=agent_class,
1086
+ mcp_config=mcp_config,
1087
+ **kwargs,
1088
+ )
1089
+ finally:
1090
+ self._mcp_context = previous_context
1091
+ self.enable_llm_crossover = previous_crossover
1092
+
1093
+ finalize_mcp_result(result, context, panel_style)
1094
+ return result
1095
+
956
1096
  # Evaluation is provided by EvaluationOps
957
1097
 
958
1098
  # LLM crossover is provided by CrossoverOps
@@ -42,6 +42,8 @@ class LlmSupport:
42
42
  frequency_penalty: float
43
43
  presence_penalty: float
44
44
 
45
+ def increment_llm_counter(self) -> None: ...
46
+
45
47
  @_throttle.rate_limited(_rate_limiter)
46
48
  def _call_model(
47
49
  self,
@@ -104,7 +106,7 @@ class LlmSupport:
104
106
  response = litellm.completion(
105
107
  model=self.model, messages=messages, **llm_config_params
106
108
  )
107
- self.llm_call_counter += 1
109
+ self.increment_llm_counter()
108
110
  return response.choices[0].message.content
109
111
  except (
110
112
  litellm_exceptions.RateLimitError,
@@ -0,0 +1,249 @@
1
+ """MCP helper utilities for the Evolutionary Optimizer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import textwrap
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from opik_optimizer.optimization_config import chat_prompt
13
+ from opik_optimizer.utils.prompt_segments import (
14
+ apply_segment_updates,
15
+ extract_prompt_segments,
16
+ )
17
+
18
+ from . import prompts as evo_prompts
19
+ from . import reporting
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class EvolutionaryMCPContext:
26
+ tool_name: str
27
+ tool_segment_id: str
28
+ original_description: str
29
+ tool_metadata: dict[str, Any]
30
+ panel_style: str
31
+
32
+
33
+ def _tool_metadata_json(metadata: dict[str, Any]) -> str:
34
+ try:
35
+ return json.dumps(metadata, indent=2)
36
+ except (
37
+ TypeError,
38
+ ValueError,
39
+ ): # pragma: no cover - defensive, shouldn't happen under normal circumstances
40
+ return str(metadata)
41
+
42
+
43
+ def generate_tool_description_variations(
44
+ optimizer: Any,
45
+ base_prompt: chat_prompt.ChatPrompt,
46
+ context: EvolutionaryMCPContext,
47
+ num_variations: int,
48
+ ) -> list[chat_prompt.ChatPrompt]:
49
+ if num_variations <= 0:
50
+ return []
51
+
52
+ instruction = textwrap.dedent(
53
+ evo_prompts.mcp_tool_rewrite_user_prompt(
54
+ tool_name=context.tool_name,
55
+ current_description=_current_tool_description(
56
+ base_prompt, context.tool_segment_id
57
+ )
58
+ or context.original_description,
59
+ tool_metadata_json=_tool_metadata_json(context.tool_metadata),
60
+ num_variations=num_variations,
61
+ )
62
+ ).strip()
63
+
64
+ try:
65
+ response = optimizer._call_model( # type: ignore[attr-defined]
66
+ messages=[
67
+ {
68
+ "role": "system",
69
+ "content": evo_prompts.mcp_tool_rewrite_system_prompt(),
70
+ },
71
+ {"role": "user", "content": instruction},
72
+ ],
73
+ is_reasoning=True,
74
+ optimization_id=getattr(optimizer, "_current_optimization_id", None),
75
+ )
76
+
77
+ payload = _extract_json_payload(response)
78
+ prompts_payload = payload.get("prompts")
79
+ if not isinstance(prompts_payload, list):
80
+ raise ValueError("LLM response missing 'prompts' list")
81
+
82
+ candidates: list[chat_prompt.ChatPrompt] = []
83
+ seen: set[str] = set()
84
+ for item in prompts_payload:
85
+ if not isinstance(item, dict):
86
+ continue
87
+ description = item.get("tool_description")
88
+ if not isinstance(description, str) or not description.strip():
89
+ continue
90
+ normalized = description.strip()
91
+ if normalized in seen:
92
+ continue
93
+ seen.add(normalized)
94
+ updated_prompt = _apply_description(base_prompt, context, normalized)
95
+ reporting.display_tool_description(
96
+ normalized,
97
+ f"Candidate tool description ({context.tool_name})",
98
+ context.panel_style,
99
+ )
100
+ candidates.append(updated_prompt)
101
+ if len(candidates) >= num_variations:
102
+ break
103
+
104
+ return candidates
105
+ except Exception as exc: # pragma: no cover - fallback path
106
+ logger.warning(f"Failed to generate MCP tool descriptions: {exc}")
107
+ return []
108
+
109
+
110
+ def initialize_population_mcp(
111
+ optimizer: Any,
112
+ prompt: chat_prompt.ChatPrompt,
113
+ context: EvolutionaryMCPContext,
114
+ ) -> list[chat_prompt.ChatPrompt]:
115
+ population_size = getattr(optimizer, "population_size", 1)
116
+ with reporting.initializing_population(
117
+ verbose=getattr(optimizer, "verbose", 1)
118
+ ) as init_pop_report:
119
+ init_pop_report.start(population_size)
120
+
121
+ population = [prompt]
122
+ num_to_generate = max(0, population_size - 1)
123
+ if num_to_generate > 0:
124
+ candidates = generate_tool_description_variations(
125
+ optimizer,
126
+ prompt,
127
+ context,
128
+ num_to_generate,
129
+ )
130
+ population.extend(candidates[:num_to_generate])
131
+
132
+ seen: set[str] = set()
133
+ final_population: list[chat_prompt.ChatPrompt] = []
134
+ for candidate in population:
135
+ key = json.dumps(candidate.get_messages())
136
+ if key in seen:
137
+ continue
138
+ seen.add(key)
139
+ final_population.append(candidate)
140
+
141
+ while len(final_population) < population_size:
142
+ final_population.append(prompt)
143
+
144
+ init_pop_report.end(final_population)
145
+ return final_population[:population_size]
146
+
147
+
148
+ def tool_description_mutation(
149
+ optimizer: Any,
150
+ prompt: chat_prompt.ChatPrompt,
151
+ context: EvolutionaryMCPContext,
152
+ ) -> chat_prompt.ChatPrompt | None:
153
+ candidates = generate_tool_description_variations(optimizer, prompt, context, 1)
154
+ if not candidates:
155
+ return None
156
+
157
+ description = _current_tool_description(candidates[0], context.tool_segment_id)
158
+ if description:
159
+ reporting.display_tool_description(
160
+ description,
161
+ f"Updated tool description ({context.tool_name})",
162
+ context.panel_style,
163
+ )
164
+ return candidates[0]
165
+
166
+
167
+ def finalize_mcp_result(
168
+ result: Any,
169
+ context: EvolutionaryMCPContext,
170
+ panel_style: str,
171
+ ) -> None:
172
+ final_tools = (
173
+ result.details.get("final_tools") if isinstance(result.details, dict) else None
174
+ )
175
+ tool_prompts = {
176
+ (tool.get("function", {}).get("name") or tool.get("name")): tool.get(
177
+ "function", {}
178
+ ).get("description")
179
+ for tool in (final_tools or [])
180
+ }
181
+ if tool_prompts.get(context.tool_name):
182
+ reporting.display_tool_description(
183
+ tool_prompts[context.tool_name],
184
+ f"Final tool description ({context.tool_name})",
185
+ panel_style,
186
+ )
187
+
188
+ if not tool_prompts and context.original_description:
189
+ tool_prompts = {context.tool_name: context.original_description}
190
+
191
+ if tool_prompts:
192
+ result.tool_prompts = tool_prompts
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Internal helpers
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ def _current_tool_description(
201
+ prompt: chat_prompt.ChatPrompt,
202
+ tool_segment_id: str,
203
+ ) -> str:
204
+ segments = {
205
+ segment.segment_id: segment for segment in extract_prompt_segments(prompt)
206
+ }
207
+ target = segments.get(tool_segment_id)
208
+ return target.content if target else ""
209
+
210
+
211
+ def _extract_json_payload(response: str) -> dict[str, Any]:
212
+ try:
213
+ return json.loads(response)
214
+ except json.JSONDecodeError:
215
+ match = re.search(r"\{.*\}", response, re.DOTALL)
216
+ if not match:
217
+ raise ValueError("No JSON object found in LLM response")
218
+ return json.loads(match.group())
219
+
220
+
221
+ def _apply_description(
222
+ prompt: chat_prompt.ChatPrompt,
223
+ context: EvolutionaryMCPContext,
224
+ description: str,
225
+ ) -> chat_prompt.ChatPrompt:
226
+ updated_prompt = apply_segment_updates(
227
+ prompt,
228
+ {context.tool_segment_id: description},
229
+ )
230
+ _sync_system_description(updated_prompt, description)
231
+ return updated_prompt
232
+
233
+
234
+ def _sync_system_description(prompt: chat_prompt.ChatPrompt, description: str) -> None:
235
+ if not prompt.system:
236
+ return
237
+
238
+ marker_start = "<<TOOL_DESCRIPTION>>"
239
+ marker_end = "<<END_TOOL_DESCRIPTION>>"
240
+
241
+ start = prompt.system.find(marker_start)
242
+ end = prompt.system.find(marker_end)
243
+ if start == -1 or end == -1 or end <= start:
244
+ return
245
+
246
+ prefix = prompt.system[: start + len(marker_start)]
247
+ suffix = prompt.system[end:]
248
+ formatted_description = f"\n{description.strip()}\n"
249
+ prompt.system = f"{prefix}{formatted_description}{suffix}"
@@ -1,10 +1,12 @@
1
1
  from typing import Any, TYPE_CHECKING
2
+ from collections.abc import Callable
2
3
 
3
4
  import json
4
5
  import logging
5
6
  import random
6
7
 
7
8
  from . import prompts as evo_prompts
9
+ from .mcp import EvolutionaryMCPContext, tool_description_mutation
8
10
  from ..optimization_config import chat_prompt
9
11
  from .. import utils
10
12
  from . import reporting
@@ -21,6 +23,8 @@ class MutationOps:
21
23
  output_style_guidance: str
22
24
  _get_task_description_for_llm: Any
23
25
  _call_model: Any
26
+ _mcp_context: EvolutionaryMCPContext | None
27
+ _update_individual_with_prompt: Callable[[Any, chat_prompt.ChatPrompt], Any]
24
28
 
25
29
  def _deap_mutation(
26
30
  self, individual: Any, initial_prompt: chat_prompt.ChatPrompt
@@ -28,6 +32,16 @@ class MutationOps:
28
32
  """Enhanced mutation operation with multiple strategies."""
29
33
  prompt = chat_prompt.ChatPrompt(messages=individual)
30
34
 
35
+ mcp_context = getattr(self, "_mcp_context", None)
36
+ if mcp_context is not None:
37
+ mutated_prompt = tool_description_mutation(self, prompt, mcp_context)
38
+ if mutated_prompt is not None:
39
+ reporting.display_success(
40
+ " Mutation successful, tool description updated (MCP mutation).",
41
+ verbose=self.verbose,
42
+ )
43
+ return self._update_individual_with_prompt(individual, mutated_prompt)
44
+
31
45
  # Choose mutation strategy based on current diversity
32
46
  diversity = self._calculate_population_diversity()
33
47
 
@@ -49,21 +63,21 @@ class MutationOps:
49
63
  " Mutation successful, prompt has been edited by randomizing words (word-level mutation).",
50
64
  verbose=self.verbose,
51
65
  )
52
- return type(individual)(mutated_prompt.get_messages())
66
+ return self._update_individual_with_prompt(individual, mutated_prompt)
53
67
  elif mutation_choice > semantic_threshold:
54
68
  mutated_prompt = self._structural_mutation(prompt)
55
69
  reporting.display_success(
56
70
  " Mutation successful, prompt has been edited by reordering, combining, or splitting sentences (structural mutation).",
57
71
  verbose=self.verbose,
58
72
  )
59
- return type(individual)(mutated_prompt.get_messages())
73
+ return self._update_individual_with_prompt(individual, mutated_prompt)
60
74
  else:
61
75
  mutated_prompt = self._semantic_mutation(prompt, initial_prompt)
62
76
  reporting.display_success(
63
77
  " Mutation successful, prompt has been edited using an LLM (semantic mutation).",
64
78
  verbose=self.verbose,
65
79
  )
66
- return type(individual)(mutated_prompt.get_messages())
80
+ return self._update_individual_with_prompt(individual, mutated_prompt)
67
81
 
68
82
  def _semantic_mutation(
69
83
  self, prompt: chat_prompt.ChatPrompt, initial_prompt: chat_prompt.ChatPrompt