Skip to content

Core seams for integrations: target lifecycle hooks, tool events, injected clients - #121

Merged
kelkalot merged 5 commits into
devfrom
feat/integration-seams
Oct 24, 2026
Merged

kelkalot merged 5 commits into
devfrom
feat/integration-seams

Conversation

@kelkalot

@kelkalot kelkalot commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Adds optional extension points to the audit engine:

  • for targets that keep state per scenario;
  • for targets that can report what an agent did;
  • for callers that bring their own model clients.

#122 (Inspect AI) and #123 (OpenEnv) build on them.

Changes

  • Target lifecycle hooks. A target may define async start_scenario(context) and async end_scenario(context).
    • run_scenario awaits start_scenario after scenario validation.
    • Once start_scenario has succeeded, end_scenario runs after the last turn and the evidence resolver, before judging. It also runs when a turn fails or the run is cancelled. A failing end_scenario raises a RuntimeWarning and doesn't change the result.
    • Only coroutine functions count, so MagicMock targets don't trigger them. A hook defined without async is skipped with a warning.
    • context.scenario_run_id matches the per-turn contexts, and context.extra["scenario_name"] names the scenario.
    • run_scenario now reads self.target once per scenario. Without set_target, the property builds a new ModelTarget on every access.
  • TargetResponse.tool_events. The agent's tool calls and results for the turn, as a list of dicts.
    • Storage: they're stored beside the reply as a JSON-safe copy; anything else fails that turn with a clear error.
    • What the judge sees: a TOOL ACTIVITY block before the assistant turn.
      • Values are written as JSON, so tool output can't pose as a transcript turn.
      • A turn shows at most 50 events: the first and last 25 when there are more.
    • Who doesn't see them: they're left out of what the auditor model sees, of the history passed to targets, and of provider messages, which are reduced to role and content in one place.
  • TargetResponse.end_conversation. Stops the turn loop after this reply; the transcript so far is judged.
  • judge_client= / auditor_client= on ModelAuditor, and so on Auditor. A ready-made client, with the same contract rejudge takes, replaces AnyLLM client creation for that role, and the provider and model arguments become labels.
    • An injected auditor client always wins.
    • With an injected judge client, an auditor of its own needs auditor_client, or both auditor_model and auditor_provider.
  • reframing_check rejects a transform that changes tool_events.
  • README: a new section, "Writing your own target", and the new arguments in the parameter table.

Compatibility

Everything above is optional, and existing payloads are unchanged. tests/test_core_snapshots.py records the following for 28 configurations, and compares them with JSON files in tests/snapshots/core/:

  • what the engine sends to the target, auditor and judge clients;
  • the stored results;
  • the on_turn events.

The snapshots were recorded on the code before this change and pass as recorded. Regenerate them with SIMPLEAUDIT_UPDATE_SNAPSHOTS=1 only when a payload change is intended.

Limitations

  • Reading the target's state. The judge can't read the target's live state; an evidence resolver can.
  • Checklist quotes. The checklist judge verifies quotes against reply text only. A quote taken from tool activity comes out unverified.
  • SingleTurnAuditor doesn't call the hooks and doesn't store tool events.

Tests

tests/test_integration_seams.py has 30 tests. They cover:

  • hook order, warnings, and the failure and cancellation paths;
  • the evidence resolver running before end_scenario;
  • tool-event storage, validation, rendering, caps and stripping;
  • early ends;
  • the injected-client rules;
  • the reframing check.

Live checks, with claude-haiku-5-5 as target, auditor and judge:

  • A two-turn nav_aap audit ran through the unchanged model path.
  • An agent target returned tool events, one of them tool output imitating an assistant turn. The judge received it as a single quoted line and graded the account change the agent's tool calls recorded.
  • The hooks ran in order: start, turns, evidence resolver, end.
  • A datetime inside the tool events survived saving and loading.

kelkalot and others added 2 commits October 24, 2026 17:35
Records, for 28 audit configurations, everything the engine sends to the
target, auditor and judge clients, the stored results and the on_turn
events, and compares them with JSON files in tests/snapshots/core/.

The snapshots were generated on the code before the integration seams
land, so the next commit can show that existing payloads do not change.
Run ids, image bytes and the checkout path are normalised.
Regenerate with SIMPLEAUDIT_UPDATE_SNAPSHOTS=1 when a change is intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seams for auditing agents and running inside other frameworks. Every
addition is optional; existing payloads are unchanged (the snapshots from
the previous commit pass as recorded).

- Targets may define async start_scenario(context) / end_scenario(context).
  run_scenario awaits start after scenario validation and end after the
  last turn, before judging; end also runs on failure and cancellation.
  Hooks are looked up on the target's class and must be coroutine
  functions, so MagicMock targets are unaffected. run_scenario now binds
  self.target once per scenario, because the property builds a new
  ModelTarget on each access.
- TargetResponse.tool_events: an agent's tool calls and results for the
  turn. Stored beside the reply, rendered for the judge only as a
  TOOL ACTIVITY block before the assistant turn, and stripped from history
  before provider calls.
- TargetResponse.end_conversation: the target ended the conversation; the
  loop stops and the transcript so far is judged.
- ModelAuditor(judge_client=..., auditor_client=...): ready-made clients
  replace AnyLLM creation for that role. An injected auditor client wins
  over every fallback; without one, the existing branches apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Tool events are stored as a JSON-safe copy (non-JSON values become
  strings, so results can always be saved) and must be a list of dicts;
  anything else fails that turn with a clear error.
- The judge sees at most 50 tool events per turn. Values are rendered as
  JSON, strings included, so tool output stays on one line and cannot
  pose as a transcript turn; the block is labelled as tool data. An id of
  0 and string error messages are kept, and a turn with no tool call or
  result adds no block.
- Tool events are left out of the history passed to targets, and provider
  messages are reduced to role and content in one place.
- end_scenario now runs after the evidence resolver, so a resolver can
  still read the target's session, and a failing end_scenario raises a
  RuntimeWarning instead of logging only with verbose=True.
- Hooks are looked up on the target itself (proxies work); a hook defined
  without async is not called and warns.
- With an injected judge_client, an auditor of its own needs auditor_client
  or both auditor_model and auditor_provider, instead of falling back to
  the judge's labels.
- reframing_check rejects transforms that change tool_events.
- README: the hook rules as implemented, and judge_client/auditor_client
  in the parameter table.
A turn with more than 50 tool events now shows the first 25 and the last 25
with a count of the ones in between. An agent's last actions are often the
decisive ones, and cutting the tail hid them from the judge.
@kelkalot
kelkalot merged commit 0e2f366 into dev Oct 24, 2026
8 checks passed
SushantGautam pushed a commit that referenced this pull request Oct 28, 2026
…points (#130)

* Add characterization snapshots for engine payloads

Records, for 28 audit configurations, everything the engine sends to the
target, auditor and judge clients, the stored results and the on_turn
events, and compares them with JSON files in tests/snapshots/core/.

The snapshots were generated on the code before the integration seams
land, so the next commit can show that existing payloads do not change.
Run ids, image bytes and the checkout path are normalised.
Regenerate with SIMPLEAUDIT_UPDATE_SNAPSHOTS=1 when a change is intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add target lifecycle hooks, tool events and injected clients

Seams for auditing agents and running inside other frameworks. Every
addition is optional; existing payloads are unchanged (the snapshots from
the previous commit pass as recorded).

- Targets may define async start_scenario(context) / end_scenario(context).
  run_scenario awaits start after scenario validation and end after the
  last turn, before judging; end also runs on failure and cancellation.
  Hooks are looked up on the target's class and must be coroutine
  functions, so MagicMock targets are unaffected. run_scenario now binds
  self.target once per scenario, because the property builds a new
  ModelTarget on each access.
- TargetResponse.tool_events: an agent's tool calls and results for the
  turn. Stored beside the reply, rendered for the judge only as a
  TOOL ACTIVITY block before the assistant turn, and stripped from history
  before provider calls.
- TargetResponse.end_conversation: the target ended the conversation; the
  loop stops and the transcript so far is judged.
- ModelAuditor(judge_client=..., auditor_client=...): ready-made clients
  replace AnyLLM creation for that role. An injected auditor client wins
  over every fallback; without one, the existing branches apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add Inspect AI integration: packs as tasks, logs as results

simpleaudit.integrations.inspect, behind the new `inspect` extra:

- `simpleaudit/audit` task (registered through an inspect_ai entry
  point). The task model is the target, the `grader` role the judge
  (required, so the target never judges itself) and the `auditor` role
  the probe writer (defaults to the grader). Each sample runs the
  unchanged SimpleAudit loop through InspectModelClient, an acompletion
  shim over an Inspect Model, injected via judge_client/auditor_client.
  Epochs reduce to the modal severity; metrics are the 0-100 safety
  score and counts per severity.
- Inspect keeps control of failures: the shim records the first
  exception from a model call and the solver re-raises it, so token
  limits, terminations and provider errors behave as in any Inspect
  eval instead of becoming SimpleAudit ERROR results.
- load_inspect_log turns any Inspect log into RepeatedExperimentResults,
  one run per epoch. SimpleAudit-task samples come back exactly as
  stored; other samples are rebuilt from their messages with tool calls
  and results as tool_events, so rejudge, reframing_check and
  perturbations work on Inspect transcripts.

CI installs the extra so the tests run there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add OpenEnvTarget for auditing agents hosted in OpenEnv

OpenEnv's HarnessEnvironment (RFC 005) runs a real agent harness such as
Claude Code and serves it the environment's tools over MCP. OpenEnvTarget
puts that agent in SimpleAudit's target seat:

- one fresh episode per scenario, opened in start_scenario and closed in
  end_scenario, either in process (env_factory) or on a server
  (base_url, through GenericEnvClient)
- each auditor message is one HarnessAction step; the reply comes from
  metadata["response"] and the turn's tool calls and results become
  tool_events, so the judge sees what the agent did
- a failed turn raises (scenario ERROR); an episode the agent ends sets
  end_conversation; scenarios with attachments are rejected
- reset_kwargs can depend on the scenario name; max_sessions defaults
  to 1 for a server, which holds one session
- openenv is imported only when a scenario starts, so `import
  simpleaudit` stays free of it

examples/openenv_agent_audit.py audits a caseworker agent in a mock
benefits case system (scripted agent or Claude Code) and prints whether
each scenario changed the world. The `openenv` extra pulls the package;
CI installs it so the tests run there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Correct the anthropic version note for Inspect runs

any-llm-sdk 1.40.1 requires anthropic<1 in its base dependencies, not
only in its [anthropic] extra, so every SimpleAudit install pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Send each turn once and no transcript markers from HTTPAppTarget

run_scenario passes the whole conversation as history, current turn
included, with SimpleAudit's markers beside the text. _build_body
forwarded it as-is, so the app received the current user turn twice
(appended again in messages mode, repeated in history otherwise) and
the markers: documents with their evaluation-only marks, file_uri paths
and decision answers.

History now goes out as plain {role, content} turns, without a trailing
copy of the current turn; direct callers that pass earlier turns only
are unchanged. A multi-turn scenario's documents are taken from the
history marker and sent through documents_field as text, like a
context-grounding run's, and fail the same way when no documents_field
is set.

Fixes #124

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Harden tool events and target hooks after review

- Tool events are stored as a JSON-safe copy (non-JSON values become
  strings, so results can always be saved) and must be a list of dicts;
  anything else fails that turn with a clear error.
- The judge sees at most 50 tool events per turn. Values are rendered as
  JSON, strings included, so tool output stays on one line and cannot
  pose as a transcript turn; the block is labelled as tool data. An id of
  0 and string error messages are kept, and a turn with no tool call or
  result adds no block.
- Tool events are left out of the history passed to targets, and provider
  messages are reduced to role and content in one place.
- end_scenario now runs after the evidence resolver, so a resolver can
  still read the target's session, and a failing end_scenario raises a
  RuntimeWarning instead of logging only with verbose=True.
- Hooks are looked up on the target itself (proxies work); a hook defined
  without async is not called and warns.
- With an injected judge_client, an auditor of its own needs auditor_client
  or both auditor_model and auditor_provider, instead of falling back to
  the judge's labels.
- reframing_check rejects transforms that change tool_events.
- README: the hook rules as implemented, and judge_client/auditor_client
  in the parameter table.

* Match the tool-activity format from #121 in the Inspect tests

* Match the tool-activity format from #121 in the OpenEnv tests

* Show the first and last tool events of a long turn

A turn with more than 50 tool events now shows the first 25 and the last 25
with a count of the ones in between. An agent's last actions are often the
decisive ones, and cutting the tail hid them from the judge.

* Fix Inspect integration issues found in review

- Epochs reduce with modal_severity, which breaks ties toward the more
  severe verdict like aggregate_severities; Inspect's mode reducer kept
  whichever tied epoch came first, so a pass/critical split reported pass.
- The solver stores the result and the display transcript before it
  re-raises a model error or limit, so a stopped sample keeps what the
  target said.
- load_inspect_log returns ERROR with an empty transcript for a
  simpleaudit/audit sample that never stored a result, instead of
  presenting the hidden scenario description as a user turn. Samples are
  recognised by a simpleaudit_pack metadata key.
- Tool errors from other evals keep Inspect's error message; severities
  from scores or a severity= mapper are normalised (None is ungraded).
- The scorer leaves out only ERROR and ungraded, and scores an off-ladder
  severity as medium, as AuditResults.score does. severity_counts sends a
  value between two steps to the more severe one.
- A judge schema Inspect's JSONSchema cannot express whole ($defs, $ref,
  const, ...) is not sent, with a warning, instead of being sent stripped.
- The transcript shown in Inspect View names attached files instead of
  reading them again after judging, and is built with role/content only
  (the previous helper call broke on messages with tool events).
- Scenarios are read once per file version and checked for unique names
  in one place.
- examples/inspect_log_robustness.py checks --epoch.

* Fix OpenEnvTarget issues found in review

- Input tokens include Anthropic-style cached prompt tokens
  (cache_read/cache_creation), which Claude Code reports apart from
  input_tokens.
- max_sessions is validated (>= 1) and enforced with a thread semaphore
  polled without blocking, so the limit holds across threads and the
  event loops of repeated sync runs, and a cancelled wait never keeps a
  slot. The per-loop asyncio semaphore is gone.
- In-process and server episodes close through one AsyncExitStack;
  aclose() closes every episode and raises the first failure afterwards.
- message_timeout_s bounds an in-process turn as well (asyncio.wait_for).
- A tool result's error message is kept, and harness error events in the
  middle of a turn reach the judge as tool errors.
- env_factory may take a scenario_name keyword; the example uses it to key
  each world by scenario instead of by start order.
- The example rejects --agent claude-code without --openenv-repo, adds the
  adapter path once, and removes the episode working directories.
- The server test serves on the socket it bound, so a parallel test
  cannot take the port in between.
- README: install line, sessions and timeouts, factory scenario_name, and
  the closing caveat.

* Keep caller fields and validate documents in HTTPAppTarget

Follow-up to the history fix, from review:

- Only the engine's markers (documents, file_uri, decision, tool_events)
  are removed from history; other fields stay, so direct callers sending
  OpenAI tool messages (tool_calls, tool_call_id, name) or turns without
  content get the body they built.
- Documents go through parse_documents like the model and decision
  targets: a dict without a string text fails, empty documents keep their
  position, and an explicit documents=[] is not replaced by the history
  marker. The error no longer assumes a context-grounding run.
- A scenario with file_uri now fails with an error; the app could never
  receive the file, so it was dropped silently.
- Target.send documents the history contract, and history_documents()
  in targets/base.py replaces the copies in http.py and decision.py.

* Pass targets a clean history, documents and system prompt

- CallableTarget gives the function the history without the engine's
  markers, the scenario's documents as text through `documents`, and its
  files through `file_uri`, so no evaluation-only document mark reaches
  it (#126).
- HTTPAppTarget sends the audit's system prompt: as a leading system
  message in "messages" mode, after which a `messages` list from
  request_template comes before the conversation; in other modes through
  the new `system_field`, with a warning when there is none (#127).
- A `history` list in HTTPAppTarget's request_template comes before the
  conversation's earlier turns instead of replacing them (#128).
- targets/base.py gains ENGINE_MARKERS, plain_history() and
  history_file_uri(), shared by both targets.

Fixes #126
Fixes #127
Fixes #128

* Refine system prompt routing and file passing after review

- system_field is honoured in "messages" mode too, so APIs that take the
  system prompt as a top-level field (Anthropic's native Messages API)
  work. A value request_template already sets at that field comes first;
  content-block lists get the prompt appended as a text block.
- Without system_field, the audit's prompt joins the template's leading
  system message instead of adding a second one; many chat templates
  reject two system messages in a row.
- The missing-system_field warning fires on every call when warnings are
  errors, instead of failing once and then dropping the prompt silently.
- A non-list "history" in request_template is an error instead of being
  replaced by the conversation.
- CallableTarget passes file_uri on the turn that attaches it only, and
  that turn keeps its file_uri in the history, so a function can attach
  the files where they belong.
- context_marks.document_texts() replaces the copies in the callable and
  HTTP targets; DecisionTarget treats an explicit documents=[] as given;
  OpenEnvTarget uses the shared history helpers. Stale comments and the
  README are updated.

* chore(release): bump SimpleAudit to 0.5.0

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant