Skip to content

feat(gateway): name the role and call behind each change to an env's state - #240

Merged
sarah-chitty-1 merged 3 commits into
mainfrom
sarahchitty/state-change-actor
Oct 20, 2026
Merged

sarah-chitty-1 merged 3 commits into
mainfrom
sarahchitty/state-change-actor

Conversation

@sarah-chitty-1

@sarah-chitty-1 sarah-chitty-1 commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

Name the role and the call behind each change to an env's state, so a verifier can tell the agent's changes from a trigger's, a user sim's or an executor's.

Today the before/after state diff says what changed but not who changed it. A task with triggers or a user sim can't separate "the agent sent this email" from "a trigger delivered this email".

  • Every write the gateway serves records the changelog rows it spanned (ids before and after the call), in the trajectory:
    • Tool calls: the result event gains changelog_id_before next to the existing changelog_id. A call that fails is logged as tool_call_failed with both ids, so a write it committed before failing still names it (failed: true).
    • Trigger tool actions: already recorded both ids.
    • Requests through the /svc and /website proxy: new http_request trajectory events, with role, route, service, method, path, status, the trigger fires the request was made for, and the two ids.
  • A request's window closes when the response finishes, not when its headers arrive. An MCP call over SSE sends headers first and writes afterwards. Only a request that wrote rows is logged.
  • New GET /changes (urn:agentenv:changes/v1, on the card): the env's changelog rows, each listing its actors. Paged with after_id and limit.
    • one actor: the row was written by exactly one call
    • several: calls overlapped (performance mode runs them concurrently)
    • none: the gateway didn't serve that write
  • snapshot_env gets include_env_changes, next to include_env_trajectory. It saves the attributed rows as JSONL and records them under metadata["env_changes"][env_id].

Why at the gateway: it's the one component that sees both the caller (role, tool or request, trigger fire) and the changelog. Writing the actor into the changelog table itself would need every service, including ones outside this repo, to tag its own database connections.

Worth a look

  1. No change to existing fields: an empty changelog still reads null in /state, /step responses and the trajectory. The attribution reads a null id before a call as an empty changelog; if that one read failed while the one after succeeded, earlier rows would also list this call, as an extra actor.
  2. Trajectory size: an http_request event is written only when a proxied non-GET request actually wrote changelog rows, so POST reads, MCP handshakes and loads before the changelog triggers go in add nothing. The trajectory's consumers here and in the hub count only tool_call events or copy it verbatim.
  3. Streaming: a request's window closes when its response has ended. The body streams through as before; the event is logged after the last byte (tested with a held-open stream).
  4. Precision: consistent mode serializes tool calls, so their windows are exact. It doesn't serialize proxied writes. Should it?
  5. Restarts: windows are kept in memory from gateway start. A restart loses attribution for earlier rows, though the trajectory file keeps them.
  6. Possible existing gap: in the test website env, the backend's message POST produced no changelog row at all, while MCP tool writes did. That suggests a changelog-coverage gap for website envs; not chased here.
  7. Merge order: this and feat(triggers): fire env triggers on requests to the env's websites #237 touch the same proxy path. Whichever merges second needs a quick rebase.

Tests

  • Unit: tst/unit/env/gateway/changes_test.py (new): the join, the recorded windows, a stream not held back by its window, a write-less request not logged, null kept for an empty changelog, and the three Greptile cases (a gateway-native read claims no earlier row, a failed call keeps its write and its error, a request keeps the trigger fire it started under). test_snapshot_env.py, gateway_card_test.py and test_env.py updated.
  • Slow integration, local: two new tests against a real service database: tool calls attributed by role, and an MCP client writing through /svc/mcp-email/mcp (the SSE case).
  • Results:
    • unit suite 8395 passed, 20 skipped
    • ruff, plugin-api (no break) and uv lock --check pass
    • local slow integration: 11 passed (both /changes tests, consistent mode, gateway trigger tests, register_env_triggers local tests)
    • the two Chromium-based website tests are still left to CI: the browser crashes inside the website-browser container on macOS, with or without this change

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; all three previous findings are addressed and no new actionable issues were found.

Summary

The PR adds /changes to show who may have written each changelog row, plus optional JSONL capture in snapshot_env.

  • The gateway names the calls behind environment changelog rows.
  • A snapshot step can save attributed environment changes as JSONL.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Tool call or proxied request] --> B[Record starting changelog ID]
  B --> C[Run call or stream response]
  C --> D[Record ending ID and actor]
  D --> E[Join actors to changelog rows]
  E --> F[GET /changes]
  F --> G[Optional snapshot JSONL]
Loading

Reviews (2) · Last reviewed commit: "fix(gateway): a result without a window ..." · Reviewed by Greptile

sarah-chitty-1 and others added 2 commits October 16, 2026 13:56
…state

Every write the gateway serves is now a window of changelog ids in its
trajectory: a tool call's `tool_call_result` carries `changelog_id_before`
next to `changelog_id`, a trigger's tool action already carried both, and a
request that may write through the REST proxy (`/svc` and `/website`, any
method but GET/HEAD/OPTIONS/TRACE) is logged as an `http_request` with its
role, route, service, method, path, status and the trigger fires it was made
for. The request's window closes when the response has ended, so a streamed
answer (an MCP call's SSE) that writes after its headers is inside it.

`GET /changes` (urn:agentenv:changes/v1) joins the env's changelog rows onto
those windows: each row lists the actors whose window holds it, one for a
row one call wrote, several for a row written while calls overlapped
(performance mode), none for a write the gateway did not serve. Paged by
`after_id` and `limit`.

`snapshot_env` gains `include_env_changes`, which records the joined rows as
JSONL in the object store under `metadata["env_changes"][env_id]`, next to
the existing `include_env_trajectory` capture.

An empty changelog now reads 0 instead of null, so the first write after
the changelog triggers go in has a window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e rows; one-line comments

- `_query_changelog_id` is back to MAX(id), so `/state`, `/step` and the
  trajectory still read null for an empty changelog; the attribution reads a
  null `before` as an empty changelog instead.
- An `http_request` trajectory event is written only for a request that
  wrote changelog rows, so reads by POST, MCP handshakes and loads before the
  changelog triggers go in add nothing to the trajectory.
- New tests: the stream is not held back by a write window, a mutating
  request that wrote nothing is not logged, and a first write on an empty
  changelog is still attributed.
- Every added comment and docstring is one line; pre-existing ones are as
  they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/env/gateway/changes.py Outdated
Comment thread src/agent_env/env/gateway/gateway.py
Comment thread src/agent_env/env/gateway/gateway.py Outdated
… keeps its write, a request keeps its cause

- A tool result with no `changelog_id_before` (a gateway-native tool) opens
  no write window, so a /step read after a write is no longer an actor on
  every earlier row; only an explicit null still reads as an empty changelog.
- A child call that raises (after committing a write, say) is logged as a
  `tool_call_failed` event with its changelog ids, so its rows name it as a
  failed call; the original error still reaches the caller unchanged.
- A proxied request's trigger cause is read when it starts, not when its
  response ends, so a fire that settles mid-stream is not lost or replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sarah-chitty-1
sarah-chitty-1 merged commit 57dbbb6 into main Oct 20, 2026
15 checks passed
@sarah-chitty-1
sarah-chitty-1 deleted the sarahchitty/state-change-actor branch October 20, 2026 16:35
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.

2 participants