Skip to content

Commit 7577fe6

Browse files
Brian KrafftCopilot
andcommitted
fix(5.4): harden recording, preserve logger namespace, add edge tests
Fixes ALL findings from /8eyes + /collab review: - SEC: Truncate recorded prompt/response to 2000 chars (data minimization) - SEC: Remove copy.deepcopy — build truncated messages instead - M-1: Logger uses evolver namespace for log filter compatibility - M-2: Add delegation seam tests (real SkillEvolver path) - L-2: Add edge case tests (conflicting keywords, JSON array, _model=None) - L-2: Add recording truncation verification test - L-2: Add logger namespace test 36 confirmation tests pass; 1,506 total pass, 127 skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b102295 commit 7577fe6

2 files changed

Lines changed: 133 additions & 7 deletions

File tree

openspace/skill_engine/evolution/confirmation.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from __future__ import annotations
99

10-
import copy
1110
import json
1211
import re
1312
from typing import TYPE_CHECKING, List
@@ -23,13 +22,16 @@
2322
SkillRecord,
2423
)
2524

26-
logger = Logger.get_logger(__name__)
25+
# Preserve evolver's logger namespace so existing log filters/alerts
26+
# continue to capture confirmation warnings without reconfiguration.
27+
logger = Logger.get_logger("openspace.skill_engine.evolver")
2728

2829
# ---------------------------------------------------------------------------
2930
# Constants
3031
# ---------------------------------------------------------------------------
3132

3233
_SKILL_CONTENT_MAX_CHARS = 12_000 # Max chars of SKILL.md in evolution prompt
34+
_RECORDING_MAX_CHARS = 2_000 # Cap recorded prompt content for data minimization
3335

3436

3537
# ---------------------------------------------------------------------------
@@ -52,7 +54,7 @@ async def llm_confirm_evolution(
5254
Returns True if LLM agrees, False otherwise.
5355
This prevents false positives from rigid threshold-based rules.
5456
55-
The confirmation prompt and response are recorded to
57+
The confirmation prompt and response are recorded (truncated) to
5658
``conversations.jsonl`` under agent_name="SkillEvolver.confirm".
5759
"""
5860
from openspace.recording import RecordingManager
@@ -70,9 +72,13 @@ async def llm_confirm_evolution(
7072

7173
confirm_messages = [{"role": "user", "content": prompt}]
7274

73-
# Record confirmation setup
75+
# Record truncated confirmation setup — full prompt goes to LLM only
76+
recorded_messages = [
77+
{"role": m["role"], "content": truncate(m["content"], _RECORDING_MAX_CHARS)}
78+
for m in confirm_messages
79+
]
7480
await RecordingManager.record_conversation_setup(
75-
setup_messages=copy.deepcopy(confirm_messages),
81+
setup_messages=recorded_messages,
7682
agent_name="SkillEvolver.confirm",
7783
extra={
7884
"skill_id": skill_record.skill_id,
@@ -90,10 +96,12 @@ async def llm_confirm_evolution(
9096
content = result["message"].get("content", "").strip().lower()
9197
confirmed = evolver._parse_confirmation(content)
9298

93-
# Record confirmation response
99+
# Record truncated confirmation response
94100
await RecordingManager.record_iteration_context(
95101
iteration=1,
96-
delta_messages=[{"role": "assistant", "content": content}],
102+
delta_messages=[
103+
{"role": "assistant", "content": truncate(content, _RECORDING_MAX_CHARS)},
104+
],
97105
response_metadata={
98106
"has_tool_calls": False,
99107
"confirmed": confirmed,

tests/test_evolution_confirmation.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,20 @@ def test_case_sensitivity(self):
9393
# Uppercase does NOT match \byes\b — by design, caller lowercases
9494
assert parse_confirmation("YES") is False
9595

96+
def test_conflicting_keywords_first_wins(self):
97+
"""When both yes and no keywords appear, positive match wins
98+
because the positive branch is checked first."""
99+
assert parse_confirmation("yes but also no") is True
100+
101+
def test_json_array_falls_through_to_keywords(self):
102+
"""JSON array is not a dict — falls through to keyword matching.
103+
The string '"proceed": true' is found as a substring, so returns True."""
104+
assert parse_confirmation('[{"proceed": true}]') is True
105+
106+
def test_json_with_extra_fields(self):
107+
"""Extra JSON fields are ignored; only 'proceed' matters."""
108+
assert parse_confirmation('{"proceed": true, "reason": "looks good"}') is True
109+
96110

97111
# ---------------------------------------------------------------------------
98112
# llm_confirm_evolution — async integration
@@ -238,6 +252,61 @@ async def test_mro_parse_confirmation_called(self):
238252

239253
evolver._parse_confirmation.assert_called_once()
240254

255+
@pytest.mark.asyncio
256+
async def test_model_fallback_to_client_model(self):
257+
"""When evolver._model is None, falls back to evolver._llm_client.model."""
258+
evolver = self._make_evolver(llm_response='{"proceed": true}')
259+
evolver._model = None
260+
evolver._llm_client.model = "fallback-model"
261+
262+
with patch(self._RM_PATCH) as mock_rm:
263+
mock_rm.record_conversation_setup = AsyncMock()
264+
mock_rm.record_iteration_context = AsyncMock()
265+
266+
result = await llm_confirm_evolution(
267+
evolver,
268+
skill_record=MagicMock(skill_id="s1"),
269+
skill_content="# Skill",
270+
proposed_type=MagicMock(value="fix"),
271+
proposed_direction="fix",
272+
trigger_context="test",
273+
recent_analyses=[],
274+
)
275+
276+
assert result is True
277+
# Verify fallback model was used
278+
call_kwargs = evolver._llm_client.complete.call_args
279+
assert call_kwargs[1]["model"] == "fallback-model"
280+
281+
@pytest.mark.asyncio
282+
async def test_recording_truncates_content(self):
283+
"""Recorded messages are truncated to _RECORDING_MAX_CHARS."""
284+
from openspace.skill_engine.evolution.confirmation import _RECORDING_MAX_CHARS
285+
286+
# Create skill content larger than recording limit
287+
big_content = "x" * (_RECORDING_MAX_CHARS + 5000)
288+
evolver = self._make_evolver(llm_response='{"proceed": true}')
289+
290+
with patch(self._RM_PATCH) as mock_rm:
291+
mock_rm.record_conversation_setup = AsyncMock()
292+
mock_rm.record_iteration_context = AsyncMock()
293+
294+
await llm_confirm_evolution(
295+
evolver,
296+
skill_record=MagicMock(skill_id="s1"),
297+
skill_content=big_content,
298+
proposed_type=MagicMock(value="fix"),
299+
proposed_direction="fix",
300+
trigger_context="test",
301+
recent_analyses=[],
302+
)
303+
304+
# Verify recorded setup messages are truncated
305+
setup_call = mock_rm.record_conversation_setup.call_args
306+
recorded_msgs = setup_call[1]["setup_messages"]
307+
for msg in recorded_msgs:
308+
assert len(msg["content"]) <= _RECORDING_MAX_CHARS + 50 # small margin for truncation suffix
309+
241310

242311
# ---------------------------------------------------------------------------
243312
# Constants
@@ -248,6 +317,11 @@ def test_skill_content_max_chars(self):
248317
assert isinstance(_SKILL_CONTENT_MAX_CHARS, int)
249318
assert _SKILL_CONTENT_MAX_CHARS == 12_000
250319

320+
def test_logger_uses_evolver_namespace(self):
321+
"""Logger preserves evolver namespace for log filter compatibility."""
322+
from openspace.skill_engine.evolution import confirmation
323+
assert confirmation.logger.name == "openspace.skill_engine.evolver"
324+
251325

252326
# ---------------------------------------------------------------------------
253327
# Backward compat
@@ -274,6 +348,50 @@ def test_parse_is_staticmethod(self):
274348
assert result is True
275349

276350

351+
# ---------------------------------------------------------------------------
352+
# Delegation seam tests (real SkillEvolver → confirmation module)
353+
# ---------------------------------------------------------------------------
354+
355+
@pytest.mark.skipif(not _HAS_EVOLVER, reason="SkillEvolver not importable")
356+
class TestDelegationSeam:
357+
"""Verify real SkillEvolver delegates to confirmation.py functions."""
358+
359+
def test_parse_confirmation_delegates(self):
360+
"""Real SkillEvolver._parse_confirmation routes to confirmation module."""
361+
evolver = object.__new__(SkillEvolver)
362+
assert evolver._parse_confirmation('{"proceed": true}') is True
363+
assert evolver._parse_confirmation('{"proceed": false}') is False
364+
assert evolver._parse_confirmation("yes do it") is True
365+
assert evolver._parse_confirmation("no skip") is False
366+
367+
@pytest.mark.asyncio
368+
async def test_llm_confirm_delegates_to_module(self):
369+
"""Real SkillEvolver._llm_confirm_evolution calls confirmation module."""
370+
evolver = object.__new__(SkillEvolver)
371+
# Wire up required attributes
372+
evolver._model = "test-model"
373+
evolver._llm_client = MagicMock()
374+
evolver._llm_client.complete = AsyncMock(
375+
return_value={"message": {"content": '{"proceed": true}'}}
376+
)
377+
378+
with patch("openspace.recording.RecordingManager") as mock_rm:
379+
mock_rm.record_conversation_setup = AsyncMock()
380+
mock_rm.record_iteration_context = AsyncMock()
381+
382+
result = await evolver._llm_confirm_evolution(
383+
skill_record=MagicMock(skill_id="s1"),
384+
skill_content="# Skill",
385+
proposed_type=MagicMock(value="fix"),
386+
proposed_direction="fix it",
387+
trigger_context="test",
388+
recent_analyses=[],
389+
)
390+
391+
assert result is True
392+
evolver._llm_client.complete.assert_awaited_once()
393+
394+
277395
# ---------------------------------------------------------------------------
278396
# Size guard
279397
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)