Skip to content

Commit 995ccb8

Browse files
Brian KrafftCopilot
andcommitted
fix(5.4): fix parse negation bugs, harden all recording paths
Round 2+3 fixes: - BUG FIX: parse_confirmation now handles negation patterns (not/never/don't) 'do not confirm', 'never confirm', 'don't proceed' correctly return False - BUG FIX: JSON string 'false' no longer truthy (strict bool/string check) - BUG FIX: Negative keywords checked FIRST (conservative fail-safe) - SEC: All 4 recording paths in evolver.py now truncate to 2000 chars - SEC: copy.deepcopy removed from all recording paths - OBS: skill_id added to iteration response_metadata for correlation - OBS: Logger namespace preserved (evolver) for filter compatibility 44 confirmation tests; 1,514 total pass, 127 skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7577fe6 commit 995ccb8

3 files changed

Lines changed: 84 additions & 26 deletions

File tree

openspace/skill_engine/evolution/confirmation.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ async def llm_confirm_evolution(
9696
content = result["message"].get("content", "").strip().lower()
9797
confirmed = evolver._parse_confirmation(content)
9898

99-
# Record truncated confirmation response
99+
# Record truncated confirmation response (include skill_id for correlation)
100100
await RecordingManager.record_iteration_context(
101101
iteration=1,
102102
delta_messages=[
@@ -105,6 +105,7 @@ async def llm_confirm_evolution(
105105
response_metadata={
106106
"has_tool_calls": False,
107107
"confirmed": confirmed,
108+
"skill_id": skill_record.skill_id,
108109
},
109110
agent_name="SkillEvolver.confirm",
110111
)
@@ -124,7 +125,7 @@ def parse_confirmation(response: str) -> bool:
124125
125126
Parsing order:
126127
1. JSON ``{"proceed": true/false}`` (with markdown-fence stripping)
127-
2. Keyword matching (yes/confirm → True, no/reject/skip → False)
128+
2. Keyword matching — negatives checked FIRST (conservative: err toward skip)
128129
3. Default: False (ambiguous → skip costly evolution)
129130
"""
130131
# Try JSON parse first
@@ -135,30 +136,42 @@ def parse_confirmation(response: str) -> bool:
135136
cleaned = re.sub(r"\n?```\s*$", "", cleaned)
136137
data = json.loads(cleaned)
137138
if isinstance(data, dict):
138-
return bool(data.get("proceed", False))
139+
proceed = data.get("proceed", False)
140+
# Strict boolean check — "false" (string) must not be truthy
141+
if isinstance(proceed, bool):
142+
return proceed
143+
if isinstance(proceed, (int, float)):
144+
return bool(proceed)
145+
# String values: explicit true/false
146+
return str(proceed).lower() in ("true", "1", "yes")
139147
except (json.JSONDecodeError, ValueError):
140148
pass
141149

142150
# Fallback: keyword matching.
143-
# - yes/no use strict word boundaries to avoid false positives
144-
# (e.g. "know" matching "no").
145-
# - confirm/reject/skip use stem-style matching so that common
146-
# LLM variants like "confirmed", "rejected", "skipping" still
147-
# parse correctly.
151+
# Negatives checked FIRST — conservative (err toward skipping).
152+
# yes/no use strict word boundaries to avoid false positives
153+
# (e.g. "know" matching "no").
154+
# confirm/reject/skip use stem-style matching so that common
155+
# LLM variants like "confirmed", "rejected", "skipping" still
156+
# parse correctly.
157+
# Negation words (not/never/don't) catch "do not confirm" patterns.
148158
_wb = re.search # shorthand
149-
if (
150-
any(w in response for w in ('"proceed": true', "proceed: true"))
151-
or _wb(r"\byes\b", response)
152-
or _wb(r"\bconfirm\w*\b", response)
153-
):
154-
return True
155159
if (
156160
any(w in response for w in ('"proceed": false', "proceed: false"))
157161
or _wb(r"\bno\b", response)
162+
or _wb(r"\bnot\b", response)
163+
or _wb(r"\bnever\b", response)
164+
or _wb(r"\bdon'?t\b", response)
158165
or _wb(r"\breject\w*\b", response)
159166
or _wb(r"\bskip\w*\b", response)
160167
):
161168
return False
169+
if (
170+
any(w in response for w in ('"proceed": true', "proceed: true"))
171+
or _wb(r"\byes\b", response)
172+
or _wb(r"\bconfirm\w*\b", response)
173+
):
174+
return True
162175

163176
# Default: skip — ambiguous response should not trigger costly evolution
164177
logger.debug("LLM confirmation response was ambiguous, defaulting to skip")

openspace/skill_engine/evolver.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
schedule_background as _schedule_background_impl,
4141
)
4242
from .evolution.confirmation import (
43+
_RECORDING_MAX_CHARS,
4344
_SKILL_CONTENT_MAX_CHARS,
4445
llm_confirm_evolution as _llm_confirm_evolution_impl,
4546
parse_confirmation as _parse_confirmation_impl,
@@ -640,9 +641,13 @@ async def _run_evolution_loop(
640641
{"role": "user", "content": prompt},
641642
]
642643

643-
# Record initial conversation setup
644+
# Record initial conversation setup (truncated for data minimization)
645+
recorded_setup = [
646+
{"role": m["role"], "content": _truncate(m["content"], _RECORDING_MAX_CHARS)}
647+
for m in messages
648+
]
644649
await RecordingManager.record_conversation_setup(
645-
setup_messages=copy.deepcopy(messages),
650+
setup_messages=recorded_setup,
646651
tools=evolution_tools if evolution_tools else None,
647652
agent_name="SkillEvolver",
648653
extra={
@@ -691,11 +696,15 @@ async def _run_evolution_loop(
691696
updated_messages = result["messages"]
692697
has_tool_calls = result.get("has_tool_calls", False)
693698

694-
# Record iteration delta
699+
# Record iteration delta (truncated for data minimization)
695700
delta = updated_messages[msg_count_before:]
701+
recorded_delta = [
702+
{"role": m["role"], "content": _truncate(m.get("content", ""), _RECORDING_MAX_CHARS)}
703+
for m in delta
704+
]
696705
await RecordingManager.record_iteration_context(
697706
iteration=iteration + 1,
698-
delta_messages=copy.deepcopy(delta),
707+
delta_messages=recorded_delta,
699708
response_metadata={
700709
"has_tool_calls": has_tool_calls,
701710
"tool_calls_count": len(result.get("tool_results", [])),
@@ -866,8 +875,12 @@ async def _apply_with_retry(
866875

867876
# Record retry setup on first retry attempt
868877
if not retry_setup_recorded:
878+
recorded_retry = [
879+
{"role": m["role"], "content": _truncate(m.get("content", ""), _RECORDING_MAX_CHARS)}
880+
for m in msg_history
881+
]
869882
await RecordingManager.record_conversation_setup(
870-
setup_messages=copy.deepcopy(msg_history),
883+
setup_messages=recorded_retry,
871884
agent_name="SkillEvolver.retry",
872885
extra={
873886
"evolution_type": ctx.suggestion.evolution_type.value,
@@ -913,8 +926,8 @@ async def _apply_with_retry(
913926
await RecordingManager.record_iteration_context(
914927
iteration=attempt + 1,
915928
delta_messages=[
916-
{"role": "user", "content": retry_prompt},
917-
{"role": "assistant", "content": new_content},
929+
{"role": "user", "content": _truncate(retry_prompt, _RECORDING_MAX_CHARS)},
930+
{"role": "assistant", "content": _truncate(new_content, _RECORDING_MAX_CHARS)},
918931
],
919932
response_metadata={
920933
"has_tool_calls": False,

tests/test_evolution_confirmation.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@ def test_json_with_markdown_fences(self):
4343
def test_json_missing_proceed_key(self):
4444
assert parse_confirmation('{"analysis": "looks good"}') is False
4545

46-
def test_json_proceed_zero_is_false(self):
46+
def test_json_proceed_string_false_is_false(self):
47+
"""String 'false' must NOT be truthy — was a real bug."""
48+
assert parse_confirmation('{"proceed": "false"}') is False
49+
50+
def test_json_proceed_string_true_is_true(self):
51+
assert parse_confirmation('{"proceed": "true"}') is True
52+
53+
def test_json_proceed_string_no_is_false(self):
54+
assert parse_confirmation('{"proceed": "no"}') is False
4755
assert parse_confirmation('{"proceed": 0}') is False
4856

4957
def test_json_proceed_one_is_true(self):
@@ -93,10 +101,34 @@ def test_case_sensitivity(self):
93101
# Uppercase does NOT match \byes\b — by design, caller lowercases
94102
assert parse_confirmation("YES") is False
95103

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
104+
def test_conflicting_keywords_negative_wins(self):
105+
"""When both yes and no keywords appear, negative wins (conservative —
106+
err toward skipping costly evolution)."""
107+
assert parse_confirmation("yes but also no") is False
108+
109+
def test_not_confirmed_returns_false(self):
110+
"""'not confirmed' should NOT trigger positive confirm match."""
111+
assert parse_confirmation("not confirmed, skip this") is False
112+
113+
def test_negated_yes_returns_false(self):
114+
"""'no, I would not say yes' — 'no' wins."""
115+
assert parse_confirmation("no, I would not say yes to this") is False
116+
117+
def test_do_not_confirm(self):
118+
"""'do not confirm' — 'not' is a negation keyword."""
119+
assert parse_confirmation("do not confirm this evolution") is False
120+
121+
def test_never_confirm(self):
122+
"""'never confirm' — 'never' is a negation keyword."""
123+
assert parse_confirmation("never confirm; leave the skill unchanged") is False
124+
125+
def test_dont_proceed(self):
126+
"""'don't proceed' — contraction negation."""
127+
assert parse_confirmation("don't proceed with this change") is False
128+
129+
def test_negation_with_json_example(self):
130+
"""Negation in prose even with JSON example should fail-safe."""
131+
assert parse_confirmation('do not proceed. Example JSON: {"proceed": true}') is False
100132

101133
def test_json_array_falls_through_to_keywords(self):
102134
"""JSON array is not a dict — falls through to keyword matching.

0 commit comments

Comments
 (0)