Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions src/sk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2125,6 +2125,39 @@ def _synthesize_exhaustion(
# removes older copies so dead instructions stop haunting later turns.
CONTROL_TAG = "[sidekick-control] "

# Verbatim loop instruction for text-only models (llama3.2:3b starts with it
# pre-seeded). Small models echo context back into answers, so the tag and
# this sentence both reached user-visible output verbatim during live probing
# (a fenced "plan-test.txt" containing the tag). _strip_control_leak removes
# them from every user-facing return; the in-loop messages keep them.
TEXT_ONLY_NOTICE = (
"[model does not support native tool calling — emit tools as ```json blocks only]"
)

_CONTINUE_NOW = "Continue with your answer now."
_CONTINUE_TOOLS = "Continue: emit the tool calls now, no more prose."


def _strip_control_leak(text: str) -> str:
"""Remove loop-machinery control notes echoed by the model. Never raises.

Covers the tag plus the exact instruction sentences built with it: a
small model repeats context verbatim, and a control sentence in the
answer reads as product behavior ("I'll create… [sidekick-control]…").
Intentional user-facing errors (residue failure, exhaustion recap,
denials) never contain these fragments and pass through untouched.
"""
try:
if not isinstance(text, str) or not text:
return text
out = text.replace(CONTROL_TAG, "")
out = out.replace(TEXT_ONLY_NOTICE, "")
out = out.replace(_CONTINUE_NOW, "")
out = out.replace(_CONTINUE_TOOLS, "")
return out
except Exception:
return text


def _drop_stale_control(messages: list) -> None:
"""Drop older control-tagged user messages in place, keeping the newest.
Expand Down Expand Up @@ -2426,7 +2459,7 @@ def _cancelled() -> bool:
# step telling the model what to do instead. Bounded, because a model
# that keeps doing this must not loop until the budget dies.
residue_attempts += 1
clean = _strip_tool_residue(msg_text)
clean = _strip_control_leak(_strip_tool_residue(msg_text))
messages.append({"role": "assistant", "content": clean or "(tool call not understood)"})
if residue_attempts > MAX_RESIDUE_RETRIES:
note = (
Expand Down Expand Up @@ -2504,7 +2537,7 @@ def _cancelled() -> bool:
}
)
continue
final_text = msg_text
final_text = _strip_control_leak(msg_text)
messages.append({"role": "assistant", "content": final_text})
# post-edit verify (refs #280): edited code that fails its syntax
# check gets targeted repair rounds instead of shipping broken.
Expand Down Expand Up @@ -2600,7 +2633,7 @@ def _cancelled() -> bool:
# fails the `.strip()` test below, so control falls out of the
# loop to `for ... else` and the user gets the progress report
# instead of a trace, or of a bare "(empty)".
final_text = m2.content or ""
final_text = _strip_control_leak(m2.content or "")
except Exception:
final_text = ""
messages.append({"role": "assistant", "content": final_text})
Expand All @@ -2609,7 +2642,7 @@ def _cancelled() -> bool:
else:
# Budget spent without a final answer: one bounded no-tools call to
# report progress + blockers instead of the bare sentinel. Never raises.
final_text = (
final_text = _strip_control_leak(
_synthesize_exhaustion(
client,
cfg.model,
Expand All @@ -2623,4 +2656,4 @@ def _cancelled() -> bool:
or "(max steps reached)"
)

return final_text
return _strip_control_leak(final_text)
8 changes: 6 additions & 2 deletions src/sk/anthropic_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,9 @@ def _summarize(text: str) -> str:
}
)
continue
return text or "(empty)"
from .agent import _strip_control_leak

return _strip_control_leak(text) or "(empty)"
batch = [
(
u.get("name", ""),
Expand Down Expand Up @@ -668,7 +670,9 @@ def _summarize(text: str) -> str:
on_token(recap_text)
except Exception:
pass
return recap_text
from .agent import _strip_control_leak

return _strip_control_leak(recap_text)
except Exception:
pass
return "(max steps reached)"
41 changes: 41 additions & 0 deletions tests/test_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,3 +716,44 @@ def fake_stream(
out = agent.run_agent("summarize the logs", [], cfg)
assert seen["tools_arg"] is None
assert out == "ok"


# --- control-tag leak: loop machinery must never reach the user ---


def test_strip_control_leak_removes_tag_and_notices():
"""Unit: tag + seeded instruction sentences go, real prose stays."""
from sk.agent import CONTROL_TAG, TEXT_ONLY_NOTICE, _strip_control_leak

assert _strip_control_leak("plain answer") == "plain answer"
assert _strip_control_leak("") == ""
assert _strip_control_leak(None) is None
leaked = (
"I'll create plan-test.txt.\n"
f"<<<UNTRUSTED source=file plan-test.txt\n{CONTROL_TAG}{TEXT_ONLY_NOTICE}\n"
"END-UNTRUSTED>>>"
)
out = _strip_control_leak(leaked)
assert CONTROL_TAG not in out and TEXT_ONLY_NOTICE not in out
assert "I'll create plan-test.txt." in out # prose preserved, machinery gone
assert _strip_control_leak("Continue with your answer now.") == ""
assert _strip_control_leak("Continue: emit the tool calls now, no more prose.") == ""


def test_run_agent_strips_echoed_control_tag(monkeypatch, tmp_path):
"""Live case: text-only model echoed the seeded control note verbatim
into its answer. The turn must return prose without the machinery."""
import sk.agent as agent

_iso(tmp_path, monkeypatch)
cfg = _cfg()
echo = f"Here is the file.\n{agent.CONTROL_TAG}{agent.TEXT_ONLY_NOTICE}\nDone."

def fake_stream(client, model, messages, tools, *a, **k):
return agent._Msg(echo, None, "", "stop")

monkeypatch.setattr(agent, "_stream_chat", fake_stream)
out = agent.run_agent("add a file plan-test.txt with hi", [], cfg)
assert agent.CONTROL_TAG not in out
assert agent.TEXT_ONLY_NOTICE not in out
assert "Here is the file." in out and "Done." in out
Loading