Repository navigation
feat(triggers): fire env triggers on requests to the env's websites - #237
sarah-chitty-1 wants to merge 8 commits into
Conversation
A `when.type: "website"` trigger matches the requests the gateway proxies
to one of its websites (`/website/<app>/...`): `app`, an optional `method`,
`path` and `status` predicates, plus `where` over the request's args (its
query and form/JSON-object body fields), `roles`, `repeat` and a
`provoking_call` barrier, which holds the website's response until the
actions have landed. Without a `status` predicate only a response below
400 matches, as only a tool call that did not fail provokes an action
trigger. An unsafe request that did not fail also prompts the state
triggers, as a non-readonly tool call does.
This lets an agent that browses with its own browser or HTTP client
provoke triggers, not only one calling the env's tools.
The firing log attributes the request (`provoking: {source: "website",
app, method, path, status, role}`), the trajectory's `trigger_fired`
carries `provoking_request`, the card declares the `website` feature, and
the explorer names website requests in the trigger timeline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keep old error text; one-line comments - The /website proxy feeds the trigger engine only while a website trigger is registered, so an env without one pays one check per request and its state triggers are never re-checked on website traffic. The request body is read inside the fail-open path. - Error messages keep their previous wording as a substring (e.g. "only valid on action triggers"), so the existing test fragments are unchanged. - Pre-existing docstrings are back as they were; every added comment and docstring is one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Suggestion: gate the step on the new This PR adds On a gateway image built before this PR, a website trigger still fails loudly. The error it gets is the gateway's 400, Before: used = {TRIGGER_NAMED_EXECUTORS: bool(named), TRIGGER_PROVOKING_ROLES: _when_has(self.triggers, "roles"),
TRIGGER_ACTION_DELAY: _delays(self.triggers), TRIGGER_EXECUTOR_HOURS: bool(self.executor_hours),
TRIGGER_UNLESS: _when_has(self.triggers, "unless")}After (with used = {TRIGGER_NAMED_EXECUTORS: bool(named), TRIGGER_PROVOKING_ROLES: _when_has(self.triggers, "roles"),
TRIGGER_ACTION_DELAY: _delays(self.triggers), TRIGGER_EXECUTOR_HOURS: bool(self.executor_hours),
TRIGGER_UNLESS: _when_has(self.triggers, "unless"),
TRIGGER_WEBSITE: any(isinstance(t.get("when"), dict) and t["when"].get("type") == "website"
for t in self.triggers)}A website task on an older gateway then fails with the step's existing message: "runs a gateway whose triggers/v1 does not declare ['website'] … redeploy it on a gateway image that does." |
Suggestion: let a website write re-check state triggers even when no website trigger is registered. See Why. Two of this PR's own tests show it. Both send the same
As a task, that looks like this. An author wants "once the agent has sent Dana the lunch invite, Dana replies", so they write the natural thing: a state trigger on the sent message. An agent that sends it with the mail server's send tool gets the reply. An agent sending the same mail through the mail website in its own browser never does, unless the task also happens to register some unrelated website trigger. The PR description says a website write "also re-checks the state triggers, as a write through a tool does", and this would make that hold whether or not a website trigger is registered. The proposal. Run the hook when a website or a state trigger is registered, and read the request body only when a website trigger needs it. The cost model stays close to "off unless used":
The one behavior change: an env that already has websites and state triggers starts re-checking state triggers on website writes. That's arguably the intended behavior, but tasks on such envs could see a state trigger fire earlier than before. Diff (applied to 7fbc1d8 and run locally:
|
| each other. Such a chain ends when a persona's turn makes no matching write, so give conversational nl actions no | ||
| `verify` (its retry forces a write), give persona prompts a stop condition, and prefer a `where` off-ramp. An | ||
| action's `delay` and `executor_hours` pace replies in virtual time, and a time trigger's `unless` makes it a deadline. | ||
| A `website` trigger fires on requests the gateway proxies to the env's websites, for agents with their own browser. |
There was a problem hiding this comment.
🤖 Drafted by Claude Code, reviewed by Edgar Arakelyan before posting.
Suggestion: say which role a browser agent's traffic counts as.
A browser sends no AgentEnv-Role header, so the gateway counts its requests as default. That works when watch_roles is left at its default. It breaks silently in the common setup where watch_roles names the agent's own role, which multi-persona tasks do: a website trigger like "when the agent opens the order page" never fires for a browser agent, with no error. Point 6 of the description covers this, but task authors read this docstring, not the PR:
| A `website` trigger fires on requests the gateway proxies to the env's websites, for agents with their own browser. | |
| A `website` trigger fires on requests the gateway proxies to the env's websites, for agents with their own browser. | |
| A browser sends no AgentEnv-Role header, so its requests count as the `default` role: when `watch_roles` names the | |
| agent's role, give a website trigger `when.roles: ["default"]`, or have the agent's client send the header. |
This steers authors to the per-trigger when.roles rather than adding default to watch_roles, which would also let anything else calling as default (a setup script, say) provoke tool-call triggers.
A question. Your Meta Muse run recorded role: psi_agent, not default. How did its requests get that role? Did the front door add the header? If that's the pattern for browser agents for now, it deserves a line in the docs too.
Longer term (not for this PR): we'd like browser traffic to carry the role natively, with no front door, so we've filed a follow-up on our side. The idea is a role link, /website/<app>/?agentenv_role=<role>, that sets a cookie scoped to /website/<app>. Every later request from that browser would then carry the role, including page loads, assets and XHR. The header is unauthenticated today, so a cookie wouldn't weaken trust; an explicit header would still win.
There was a problem hiding this comment.
Applied in 5423ce8: the docstring says a browser counts as default and to use when.roles: ["default"] or send the header. On your question: in our Muse run, our products-track front door proxies the portal's site requests to the gateway and sets AgentEnv-Role to the run's role (any client-sent role header is dropped); that pattern is now a line in the same docstring, and we'd welcome the role-cookie follow-up.
… on website writes, document the browser's role Edgar's three suggestions: - `register_env_triggers` lists `website` among the features its triggers use, so an older gateway gets the step's "does not declare ['website'] … redeploy" message instead of a 400. - The /website hook runs when a website or a state trigger is registered; the request body is read only for a website trigger. A website write now re-checks state triggers like a tool write, with or without a website trigger (new zero-cost test: neither kind registered, the engine is never reached). - The step's docstring says a browser's requests count as `default`, how to target them, and that a front door can set the header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Feature gate: applied in 5423ce8: |
|
State re-checks on website writes: applied in 5423ce8: your diff as is (docstring kept to one line), with both test changes; the PR description now has the new cost model and the behaviour change you named. |
… window at the backend's answer A tool call's window already closes before its barrier; a /website request's closed after its last byte, which the barrier delays until the trigger's actions land, so their writes read as both the trigger's and the request's. A sized response closes its window before the barrier; a streamed one keeps it to its last byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rather than trusting Content-Length Ending the window at the headers lost the request's own writes made while a sized body still went out. The body is now read ahead (finite, so it cannot hang) and the window ends at its last byte, before any trigger fires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and only up to 8 MiB A sized write no barrier matches streams as before; one a barrier could hold is read ahead (bounded), so its window still ends at its last byte before the trigger runs. A larger body keeps its window to the last byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… once its body has ended It streams as before, but its trigger and state checks now run at its last byte, so they see what it wrote while its body went out, and its change window closes before any of them run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What
A new trigger type,
when.type: "website", that fires on requests the gateway proxies to an env's websites (/website/<app>/...).Action triggers only see tool calls today. An agent that browses with its own browser or HTTP client (an external A2A agent, a computer-use agent with its own browser, a script) never calls the env's
browser_*tools, so "when the agent opens the order page" never fires for it. Its page traffic does go through the gateway's website proxy, so the gateway can match on that.{"id": "carrier-emails", "when": {"type": "website", "app": "porchbite", "method": "GET", "path": {"regex": "^/api/orders/"}}, "barrier": {"at": "provoking_call"}, "actions": [...]}app: required, and must be one of the env's websites (400 otherwise)method: optional, upper-casedpathandstatus: optional, using the existingregex/equals/existscheckswhereon the request's args (query parameters plus form or JSON-object body fields; the body wins a clash),roles,repeat,${args.*}in templates, and theprovoking_callbarrier, which holds the website's response until the trigger's actions have landed.statuscheck, only responses below 400 match, the same way a failed tool call never fires a trigger. Astatuscheck can match 4xx/5xx on purpose.GET /triggers/state): same contract. Adetectedevent names the request asprovoking: {source: "website", app, method, path, status, role}. The query string and body are never logged. The trajectory'strigger_firedalso carriesprovoking_request.websiteis added to the triggers/v1featureslist, andregister_env_triggerschecks it, so a website trigger on an older gateway gets the step's "does not declare ['website'] … redeploy" message.Worth a look
/websiteproxy hands requests to the trigger engine only while a website or a state trigger is registered; with neither, it pays one check per request. The request body is read only when a website trigger needs it, GETs never re-check state triggers, and a write schedules one background check per armed state trigger, as a tool write does. The response is never held for a state re-check./website/<app>/traffic counts. Direct backend calls through/svc/<app>/don't. A broaderhttptype could be a follow-up.${result.*}andwhereon the result are refused at registration. Capturing small JSON responses could be a follow-up.AgentEnv-Roleheader and counts asdefault. Whenwatch_rolesnames the agent's role, give a website triggerwhen.roles: ["default"], or have the client (or a front door proxying to the gateway) send the header; the step's docstring says so. A role cookie is a possible follow-up.?for a website detection.unlessstill matches only tool calls, and website requests themselves aren't written to the trajectory, onlyprovoking_requestontrigger_fired./changes): a website request's triggers and state re-checks run once its response body has ended, after its change window closes, so a write made while the body is sent belongs to the request and a trigger's writes belong only to the trigger. When a barrier could hold the request, a sized body up to 8 MiB is read ahead first so the window can close before the barrier waits; a streamed or larger body keeps the window open to its last byte, so a trigger write landing meanwhile can still list both actors. Tests for each case are inchanges_test.py.Tests
tst/unit/env/gateway/triggers_website_test.py, 27 tests, plus the feature gate intest_register_env_triggers.py:/website/slack/.ruffcleanuv lock --checkOKregister_env_triggersall passTested end to end with an external agent's own browser
Three agents opened a stayncook link through our front door on Modal, each with its own browser: a plain Playwright script,
claude-opus-5-5driving headless Chrome, and Meta Muse's own cloud browser. Their requests went through the gateway's/website/stayncook/proxy; none of them used the env'sbrowser_*tools.With Muse, the barrier held the first page about 57 ms, and the gateway sent the 200 0.3 ms after
fired. Muse then found the stay under Canceled reservations, and the state grader counted the cancellation as the world's change, not the agent's (verdict: pass).GET /:provoking: {source: website, app: stayncook, method: GET, path: /, status: 200, role: psi_agent}, once (fire_count: 1).provoking_callbarrier held the page until the host's cancellation and its verify step had landed, about 45 ms. The 200 went out 0.2 ms afterfiredin the gateway's own log.Docs note: the provoking response is held, not re-rendered. A trigger meant to change what the agent sees should key on the page load (the HTML document), not on the data call it wants to change. Otherwise the barrier holds a response that already has the old data.
🤖 Generated with Claude Code