Repository navigation
Convert broken_env, reward_hacking, and sandbagging to scout - #30
Conversation
1d8af75 to
abf7e73
Compare
52d7d5e to
a9a3575
Compare
This commit introduces two new scanners for detecting reward hacking and sandbagging in transcripts. It includes utility functions for chunking transcripts and parsing JSON with Pydantic, along with necessary updates to the project dependencies and registry.
an incorrect shape
- Fix problem when we have short transcript - Allow to have a version of the prompt for a single transcript at once
a9a3575 to
5933754
Compare
0b6c03c to
717321a
Compare
522da8a to
ecd9720
Compare
ecd9720 to
2639571
Compare
| @@ -4,4 +4,6 @@ | |||
| !uv.lock | |||
| !README.md | |||
There was a problem hiding this comment.
Why the README.md files? Is it because they're specified in the pyproject.toml files and so uv will expect them to be there?
(If so, maybe you don't need to specify this one as the top-level pyproject.toml file no longer mentions a README)
| fi | ||
|
|
||
| package_name="metr_${package}" | ||
| verion_file="src/${package_name}/version.py" |
There was a problem hiding this comment.
| verion_file="src/${package_name}/version.py" | |
| version_file="src/${package_name}/version.py" |
| PREVIOUS_VERSION="$(uv version --short)" | ||
| git checkout HEAD -- pyproject.toml | ||
| NEW_VERSION="$(uv version --bump=stable --short 2>/dev/null || uv version --bump=patch --short)" | ||
| echo "__version__ = \"${NEW_VERSION}\"" > "${verion_file}" |
There was a problem hiding this comment.
| echo "__version__ = \"${NEW_VERSION}\"" > "${verion_file}" | |
| echo "__version__ = \"${NEW_VERSION}\"" > "${version_file}" |
| def _get_agent_tools( | ||
| transcript: inspect_scout.Transcript, | ||
| ) -> str | None: | ||
| agent_tools = _AGENT_TOOLS.get(None) | ||
| if agent_tools: | ||
| return agent_tools | ||
|
|
||
| for event in reversed(transcript.events): | ||
| if isinstance(event, inspect_ai.event.ModelEvent) and event.tools: | ||
| agent_tools = json.dumps([tool.model_dump() for tool in event.tools]) | ||
| _AGENT_TOOLS.set(agent_tools) | ||
| return agent_tools | ||
|
|
||
| return None |
There was a problem hiding this comment.
Is this actually a safe way to get the agent's tools? For example, here is an ai_rd_fix_embedding run where it appears to me that the last call to the model (in epoch 1) only supplies the rate_options tool.
There was a problem hiding this comment.
(Also, I get a bunch of errors when I try and scan that eval log using the command scout scan metr_scanners/broken_env_scanner -T 2025-09-25T15-19-41+00-00_ai-rd-fix-embedding_a7nCsDBesrNVNNmSKNeLfN.eval --model openai/gpt-5-mini, specifically the exception ijson.common.IncompleteJSONError: lexical error: invalid char in json text.. I wasn't able to debug this because I couldn't work out how to catch the exception in the debugger)
There was a problem hiding this comment.
it appears to me that the last call to the model (in epoch 1) only supplies the
rate_optionstool.
TRIFRAME!! 😆
| response_schema=inspect_ai.model.ResponseSchema( | ||
| name=ResultClass.__name__, | ||
| json_schema=inspect_ai.util.json_schema(ResultClass), | ||
| ) |
There was a problem hiding this comment.
Re the Inspect docs and the Scout code: was there a specific reason for using structured output instead of an "answer tool"? Any idea if performance is the same/better than using a tool?
There was a problem hiding this comment.
The current broken_env scorer uses structured output, and we seem happy with its output. I think Romain (the trialee who wrote most of this code) simply copied that. My preference would be that we have a single consistent method for running scanners. Happy to change, but I wouldn't have the time to run that comparison myself.
There was a problem hiding this comment.
This looks pretty similar to Scout's built in LLM scanner, except with support for chunking messages and more capable retry logic - I guess it probably wouldn't shorten the code to implement this on top of the built in LLM scanner if we still had to implement chunking, and it wouldn't be possible to replace its retry logic, so this seems okay to me
| for chunk in result_chunks: | ||
| if "[M1]" in chunk.text and "[M2]" in chunk.text: | ||
| test_text = "I found an issue in [M1] and [M2]" | ||
| refs = chunk.extract_references(test_text) | ||
|
|
||
| assert len(refs) == 2 | ||
| assert refs[0].cite == "[M1]" | ||
| assert refs[0].type == "message" | ||
| assert refs[1].cite == "[M2]" | ||
| assert refs[1].type == "message" | ||
| break | ||
| else: | ||
| chunk = result_chunks[0] | ||
| message_ids = re.findall(r"\[M(\d+)\]", chunk.text) | ||
| if len(message_ids) >= 1: | ||
| test_text = f"I found an issue in [M{message_ids[0]}]" | ||
| refs = chunk.extract_references(test_text) | ||
| assert len(refs) == 1 | ||
| assert refs[0].cite == f"[M{message_ids[0]}]" |
There was a problem hiding this comment.
It appears to me that "[M1]" in chunk.text and "[M2]" in chunk.text will always resolve to False, and so none of the block beneath that if statement will ever execute. Is that what you intended?
There was a problem hiding this comment.
Thanks, I need to review the tests in more detail
| message_ids = re.findall(r"\[M(\d+)\]", chunk.text) | ||
|
|
||
| if message_ids: |
There was a problem hiding this comment.
Is there any reason to expect that message_ids would ever be falsy, and if so would it actually be appropriate to skip all the if statements below?
|
I've rewritten the tests to be more clear. Thanks for the close review! |
| async def _scan_transcript_single_pass( | ||
| transcript: inspect_scout.Transcript, | ||
| model: inspect_ai.model.Model, | ||
| result_type: type[QuotedResult], | ||
| *, | ||
| prompt_prefix: str, | ||
| prompt_suffix: str, | ||
| **prompt_kwargs: str, | ||
| ) -> inspect_scout.Result: | ||
| transcript_str, extract_fn = await inspect_scout.messages_as_str( | ||
| transcript, include_ids=True | ||
| ) | ||
|
|
||
| return await _scan_with_retry( | ||
| model, | ||
| result_type, | ||
| _render_partial_template( | ||
| _PROMPT_TEMPLATE_SINGLE_PASS, | ||
| **{ | ||
| _PREFIX_KEY: prompt_prefix, | ||
| _SUFFIX_KEY: prompt_suffix, | ||
| _TRANSCRIPT_KEY: DONT_RENDER, | ||
| }, | ||
| ).format( | ||
| **prompt_kwargs, | ||
| **{_TRANSCRIPT_KEY: transcript_str}, | ||
| ), | ||
| extract_fn, | ||
| ) | ||
|
|
||
|
|
||
| async def _scan_transcript_chunked( | ||
| transcript: inspect_scout.Transcript, | ||
| model: inspect_ai.model.Model, | ||
| result_type: type[QuotedResult], | ||
| *, | ||
| prompt_prefix: str, | ||
| prompt_suffix: str, | ||
| early_messages_count: int = 5, | ||
| max_chunk_size: int = 150_000, | ||
| **prompt_kwargs: str, | ||
| ) -> inspect_scout.Result: |
There was a problem hiding this comment.
I'm pretty sure these two functions can be combined into one by checking if there's only one chunk. It also seems like poor separation of concerns that the chunking function is doing template rendering
| async def scan(transcript: inspect_scout.Transcript) -> inspect_scout.Result: | ||
| model = inspect_ai.model.get_model(model_name) | ||
| prompt_kwargs = prompt_values(transcript) if prompt_values else {} | ||
| if len(transcript.messages) <= early_messages_count: |
There was a problem hiding this comment.
This seems like the wrong thing to be checking (message count vs. chunk length).
| if git cat-file -e HEAD~:pyproject.toml 2>/dev/null; then | ||
| git checkout HEAD~ -- pyproject.toml | ||
| previous_version="$(uv version --short)" | ||
| git checkout HEAD -- pyproject.toml | ||
| else | ||
| previous_version="" | ||
| fi | ||
|
|
||
| git checkout HEAD~ -- pyproject.toml | ||
| PREVIOUS_VERSION="$(uv version --short)" | ||
| git checkout HEAD -- pyproject.toml | ||
| NEW_VERSION="$(uv version --bump=stable --short 2>/dev/null || uv version --bump=patch --short)" | ||
| echo "__version__ = \"${NEW_VERSION}\"" > "${verion_file}" | ||
| echo "__version__ = \"${NEW_VERSION}\"" > "${version_file}" | ||
|
|
||
| git add pyproject.toml "${verion_file}" | ||
| git add pyproject.toml "${version_file}" | ||
| git tag "${package_name}/v${NEW_VERSION}" | ||
| commit_message+="${package_name}: ${PREVIOUS_VERSION} → ${NEW_VERSION}\n" |
There was a problem hiding this comment.
Need to use the same case for PREVIOUS_VERSION everywhere. Shellcheck catches this:
Line 23:
previous_version=""
^-- [SC2034](https://www.shellcheck.net/wiki/SC2034) (warning): previous_version appears unused. Verify use (or export if used externally).
Line 31:
commit_message+="${package_name}: ${PREVIOUS_VERSION} → ${NEW_VERSION}\n"
^-- [SC2153](https://www.shellcheck.net/wiki/SC2153) (info): Possible misspelling: PREVIOUS_VERSION may not be assigned. Did you mean previous_version?
There was a problem hiding this comment.
Maybe we should try using actionlint in CI and/or our devcontainers as it apparently runs shellcheck on Actions workflow run steps (it has an official Docker image and instructions for using in GitHub Actions too)
| async for chunk in transcript_messages_to_chunks(messages, max_chunk_size=50) | ||
| async for chunk in chunks.transcript_messages_to_chunks( | ||
| messages, max_chunk_size=50 | ||
| ) | ||
| ] | ||
|
|
||
| for chunk in result_chunks: | ||
| message_count = len(re.findall(r"\[M\d+\]", chunk.text)) | ||
| assert message_count >= 1 |
There was a problem hiding this comment.
If the max_chunk_size is smaller than each of the messages, then surely this should be
| assert message_count >= 1 | |
| assert message_count == 1 |
for all the chunks?
4c425dd to
1e6e0d8
Compare
1e6e0d8 to
8331f21
Compare
|
Publish workflow failed 😆 |
See the results of the scanners here: https://inspect-ai.internal.metr.org/scan/hcast-claude-opus-4-5-triframe-n4-202511#/scan/scan_id=n7sy5pM9XT5RovVVuMSFsM?scanner=reward_hacking_scanner
Currently the broken_env scanner results won't load, I think I can fix that :)