Skip to content

Commit 9e9913a

Browse files
committed
enh: use cached plugin if the config and module_path are unchanged (single plugin initialization call).
Signed-off-by: habeck <habeck@us.ibm.com>
1 parent 4925b31 commit 9e9913a

3 files changed

Lines changed: 78 additions & 35 deletions

File tree

cpex/framework/isolated/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool:
116116
# Compare hashes
117117
cached_hash = metadata.get("requirements_hash")
118118
if cached_hash != current_hash:
119-
logger.info("Requirements changed. Cached hash: %s, Current hash: %s",cached_hash, current_hash)
119+
logger.info("Requirements changed. Cached hash: %s, Current hash: %s", cached_hash, current_hash)
120120
return False
121121

122122
logger.info("Valid venv cache found for %s", venv_path)

cpex/framework/isolated/worker.py

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"""
1010

1111
import asyncio
12+
import hashlib
1213
import importlib.metadata
1314
import json
1415
import logging
@@ -28,6 +29,39 @@
2829
logger = logging.getLogger(__name__)
2930

3031

32+
class TaskProcessor:
33+
"""
34+
A Caching task processor that only reloads the plugin if the config has changed.
35+
"""
36+
37+
config_hash: str
38+
module_path_hash: str
39+
hook_ref: HookRef | None
40+
executor: PluginExecutor | None
41+
42+
def __init__(self) -> None:
43+
"""Initialize defaults."""
44+
hasher = hashlib.sha256()
45+
hasher.update(b"")
46+
self.config_hash = hasher.hexdigest()
47+
self.module_path_hash = self.config_hash
48+
self.hook_ref = None
49+
self.executor = None
50+
51+
def compute_hash(self, json_config_or_module_path: str):
52+
"""Compute the hash of the supplied string"""
53+
hasher = hashlib.sha256()
54+
hasher.update(json_config_or_module_path.encode())
55+
return hasher.hexdigest()
56+
57+
def initialize(self, hook_ref: HookRef, executor: PluginExecutor, json_config: str, module_path: str):
58+
"""Assign locals, and compute hashes."""
59+
self.hook_ref = hook_ref
60+
self.executor = executor
61+
self.config_hash = self.compute_hash(json_config_or_module_path=json_config)
62+
self.module_path_hash = self.compute_hash(json_config_or_module_path=module_path)
63+
64+
3165
def get_environment_info():
3266
"""Get information about current Python environment."""
3367
return {
@@ -55,7 +89,7 @@ def get_proper_config(name, module_path):
5589
return None
5690

5791

58-
async def process_task(task_data):
92+
async def process_task(task_data, tp: TaskProcessor):
5993
"""Process the task received from parent."""
6094
task_type = task_data.get("task_type")
6195

@@ -71,28 +105,33 @@ async def process_task(task_data):
71105
json_config = task_data.get("config")
72106
config_raw = json.loads(json_config)
73107
module_path: str = task_data.get("script_path")
74-
sys.path.append(str(Path(module_path).resolve()))
75-
config = get_proper_config(config_raw.get("name"), module_path)
76-
hook_type = task_data.get(HOOK_TYPE)
77-
cls_name: str = task_data.get("class_name")
78-
mod_name, n_cls_name = parse_class_name(cls_name)
79-
module: ModuleType = importlib.import_module(mod_name)
80-
# cool, we found the module, and verified it implemented the hook type.
81-
class_ = getattr(module, n_cls_name)
82-
plugin_type = cast(Type[Plugin], class_)
83-
plugin = plugin_type(config)
84-
await plugin.initialize()
85-
# now invoke the hook
86-
plugin_ref = PluginRef(plugin)
87-
hook_ref = HookRef(hook_type, plugin_ref)
88-
executor = PluginExecutor(None, 30)
108+
if tp.module_path_hash != tp.compute_hash(module_path) or tp.config_hash != tp.compute_hash(json_config):
109+
sys.path.append(str(Path(module_path).resolve()))
110+
config = get_proper_config(config_raw.get("name"), module_path)
111+
hook_type = task_data.get(HOOK_TYPE)
112+
cls_name: str = task_data.get("class_name")
113+
mod_name, n_cls_name = parse_class_name(cls_name)
114+
module: ModuleType = importlib.import_module(mod_name)
115+
# cool, we found the module, and verified it implemented the hook type.
116+
class_ = getattr(module, n_cls_name)
117+
plugin_type = cast(Type[Plugin], class_)
118+
plugin = plugin_type(config)
119+
await plugin.initialize()
120+
# now invoke the hook
121+
plugin_ref = PluginRef(plugin)
122+
hook_ref = HookRef(hook_type, plugin_ref)
123+
executor = PluginExecutor(None, 30)
124+
tp.initialize(hook_ref=hook_ref, executor=executor, json_config=json_config, module_path=module_path)
89125
# retrieve the context
90126
context = task_data.get("context")
91127
plugin_context = PluginContext(
92128
state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata")
93129
)
94-
result = await executor.execute_plugin(
95-
hook_ref, payload=task_data.get("payload"), local_context=plugin_context, violations_as_exceptions=False
130+
result = await tp.executor.execute_plugin(
131+
hookref=tp.hook_ref,
132+
payload=task_data.get("payload"),
133+
local_context=plugin_context,
134+
violations_as_exceptions=False,
96135
)
97136
return result
98137

@@ -102,6 +141,8 @@ async def main():
102141
logger.info("Worker process started, waiting for tasks...")
103142

104143
try:
144+
# Cache the plugin so that it only has to be initialized once
145+
tp = TaskProcessor()
105146
# Continuously read and process tasks
106147
while True:
107148
try:
@@ -125,7 +166,7 @@ async def main():
125166
break
126167

127168
# Process the task
128-
response = await process_task(task_data)
169+
response = await process_task(task_data, tp)
129170

130171
# Serialize response
131172
if response:

tests/unit/cpex/framework/isolated/test_worker.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import pytest
1818

19-
from cpex.framework.isolated.worker import get_environment_info, get_proper_config, main, process_task
19+
from cpex.framework.isolated.worker import TaskProcessor, get_environment_info, get_proper_config, main, process_task
2020

2121

2222
class TestWorkerFunctions:
@@ -81,9 +81,10 @@ def test_get_proper_config_no_plugins(self, mock_load_config):
8181
@pytest.mark.asyncio
8282
async def test_process_task_info(self):
8383
"""Test processing info task."""
84-
task_data = {"task_type": "info"}
85-
86-
result = await process_task(task_data)
84+
config_dict = {"name": "test_plugin", "kind": "isolated_venv", "config": {}}
85+
task_data = {"task_type": "info", "config": json.dumps(config_dict)}
86+
tp = TaskProcessor()
87+
result = await process_task(task_data, tp)
8788

8889
assert result["status"] == "success"
8990
assert "environment" in result
@@ -132,8 +133,8 @@ async def test_process_task_load_and_run_hook_success(self, mock_executor_class,
132133
"payload": {"name": "test_tool", "args": {}},
133134
"context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}},
134135
}
135-
136-
result = await process_task(task_data)
136+
tp = TaskProcessor()
137+
result = await process_task(task_data, tp=tp)
137138

138139
assert result is not None
139140
mock_plugin_instance.initialize.assert_called_once()
@@ -155,10 +156,10 @@ async def test_process_task_load_and_run_hook_no_config(self, mock_get_config):
155156
"payload": {},
156157
"context": {"state": {}, "global_context": {}, "metadata": {}},
157158
}
158-
159+
tp = TaskProcessor()
159160
# Should raise an error or return None
160161
with pytest.raises((AttributeError, TypeError)):
161-
await process_task(task_data)
162+
await process_task(task_data, tp)
162163

163164
@pytest.mark.asyncio
164165
@patch("cpex.framework.isolated.worker.get_proper_config")
@@ -180,9 +181,9 @@ async def test_process_task_load_and_run_hook_import_error(self, mock_import, mo
180181
"payload": {},
181182
"context": {"state": {}, "global_context": {}, "metadata": {}},
182183
}
183-
184+
tp = TaskProcessor()
184185
with pytest.raises(ImportError):
185-
await process_task(task_data)
186+
await process_task(task_data, tp)
186187

187188
@pytest.mark.asyncio
188189
@patch("cpex.framework.isolated.worker.get_proper_config")
@@ -214,6 +215,7 @@ async def test_process_task_with_different_hook_types(self, mock_executor_class,
214215
mock_executor_class.return_value = mock_executor
215216

216217
hook_types = ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"]
218+
tp = TaskProcessor()
217219

218220
for hook_type in hook_types:
219221
config_dict = {"name": "test_plugin", "kind": "isolated_venv"}
@@ -226,17 +228,16 @@ async def test_process_task_with_different_hook_types(self, mock_executor_class,
226228
"payload": {},
227229
"context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}},
228230
}
229-
230-
result = await process_task(task_data)
231+
result = await process_task(task_data, tp)
231232
assert result is not None
232233

233234
@pytest.mark.asyncio
234235
async def test_process_task_unknown_task_type(self):
235236
"""Test processing task with unknown task type."""
236237
task_data = {"task_type": "unknown_type"}
237-
238+
tp = TaskProcessor()
238239
# Should return None or handle gracefully
239-
result = await process_task(task_data)
240+
result = await process_task(task_data, tp)
240241
assert result is None
241242

242243
@pytest.mark.asyncio
@@ -282,8 +283,9 @@ async def test_process_task_with_metadata(self, mock_executor_class, mock_import
282283
"metadata": {"custom": "data"},
283284
},
284285
}
286+
tp = TaskProcessor()
285287

286-
result = await process_task(task_data)
288+
result = await process_task(task_data, tp)
287289

288290
assert result is not None
289291
# Verify executor was called with proper context

0 commit comments

Comments
 (0)