Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
11c8a8e
fix(triggers): wait for a slow executor up to its own timeout, not 30…
sarah-chitty-1 Nov 8, 2026
49e6789
always_on: import the local build
sarah-chitty-1 Nov 9, 2026
643809d
Merge sarahchitty/fd-5333-executor-read-timeout (FD-5333, PR #544)
sarah-chitty-1 Nov 9, 2026
c03dd4c
always_on: message_user, stay_quiet, the nudge and the wake reminder …
sarah-chitty-1 Nov 9, 2026
4474070
always_on: the core loop as decided (FD-5366)
sarah-chitty-1 Nov 9, 2026
2698505
always_on: the agent gets the task's time, not the computer's (FD-4408)
sarah-chitty-1 Nov 9, 2026
8a4cec2
always_on: the agent can schedule its own wake-ups, on by default for…
sarah-chitty-1 Nov 9, 2026
d63ba95
always_on: user_sim takes the user's email (FD-5366)
sarah-chitty-1 Nov 9, 2026
97f77c0
always_on: drop test-rig wording from docstrings
sarah-chitty-1 Nov 9, 2026
1d5a295
always_on: move the user_sim step into user_sim.py, with the interfac…
sarah-chitty-1 Nov 9, 2026
f5fca2f
always_on: each read says whether done would end the run
sarah-chitty-1 Nov 9, 2026
f8bdf56
always_on: her done reaches the agent; the opening request is in person
sarah-chitty-1 Nov 9, 2026
12e03f5
always_on: tests for everything the loop hands the user sim
sarah-chitty-1 Nov 9, 2026
d36c625
triggers: report each fire and executor call in state; opt-in side-by…
sarah-chitty-1 Nov 9, 2026
9ed4ad4
always_on: keep a short summary in the run record; the full timeline …
sarah-chitty-1 Nov 9, 2026
baa1edb
triggers: count a fire as running from its detection, not from when i…
sarah-chitty-1 Nov 9, 2026
d76d30a
triggers: a delay that ends between microseconds goes on when the clo…
sarah-chitty-1 Nov 9, 2026
641410e
always_on: settle on contacts' real progress; one trigger's replies s…
sarah-chitty-1 Nov 9, 2026
ff4ea74
always_on: clean endings - a final check-in always, a last-turn notic…
sarah-chitty-1 Nov 9, 2026
5df7cb9
always_on: graders read what the user saw; failed turns are errors (F…
sarah-chitty-1 Nov 9, 2026
5df8fdb
always_on: who did what - app changes by wake, the message each reply…
sarah-chitty-1 Nov 9, 2026
bf920d4
rubrics_verifier: grade an always_on prompt from the user's chat and …
sarah-chitty-1 Nov 9, 2026
f7c903e
always_on: a real-time budget so a run ends cleanly inside the hub's …
sarah-chitty-1 Nov 9, 2026
3bfdf7e
always_on: the module docstring speaks of the user, not her
sarah-chitty-1 Nov 9, 2026
856db39
triggers: side-by-side fires only for contact replies; each fire keep…
sarah-chitty-1 Nov 9, 2026
6b4c92c
user_chat: message_user and stay_quiet are read-only for the world
sarah-chitty-1 Nov 9, 2026
a4dc0cc
rubrics_verifier: skip an errored always_on run before checking how i…
sarah-chitty-1 Nov 9, 2026
e8c801e
clock: set_rate changes the rate on the current arm, keeping time tri…
sarah-chitty-1 Nov 9, 2026
2976de5
always_on: the process log gets event names, ids, times, counts and s…
sarah-chitty-1 Nov 9, 2026
e85a475
always_on: a world that only repeats ends a run with no end_time; lon…
sarah-chitty-1 Nov 9, 2026
86e7007
always_on: a user sim's schedule or response times need the task's cl…
sarah-chitty-1 Nov 9, 2026
ac6bb5e
always_on: a user's night may run past midnight; back-to-back busy bl…
sarah-chitty-1 Nov 9, 2026
70b9494
always_on: the prompt's file parts reach the agent on the opening wake
sarah-chitty-1 Nov 9, 2026
c8da050
always_on: the run's user_model sets the user sim agent's model, and …
sarah-chitty-1 Nov 9, 2026
935f0ad
always_on: the task's clock runs only during the agent's turns; froze…
sarah-chitty-1 Nov 9, 2026
b2b8727
always_on: every part of a wake is hard-capped, so budget plus one tu…
sarah-chitty-1 Nov 9, 2026
58acb54
always_on: put back the gateway settings it changed; the rules reach …
sarah-chitty-1 Nov 9, 2026
802aa2f
always_on: the record tells undelivered replies apart, keeps leftover…
sarah-chitty-1 Nov 9, 2026
109d024
always_on: one read of an env's changelog keeps at most 200 changes i…
sarah-chitty-1 Nov 9, 2026
3a9cca3
always_on: a user sim read keeps the plug-in's retried and first_answ…
sarah-chitty-1 Nov 9, 2026
44acf80
always_on: an agent turn that comes back canceled or rejected ends th…
sarah-chitty-1 Nov 9, 2026
85c95ba
always_on: a turn that ends past end_time is followed by the final ch…
sarah-chitty-1 Nov 9, 2026
9e38c6f
always_on: the last read is no earlier than the task's time when the …
sarah-chitty-1 Nov 9, 2026
85555b8
gateway: turning the user chat off or clearing it leaves a backing se…
sarah-chitty-1 Nov 9, 2026
87e7120
always_on: take only the agent's role's messages, and set aside what …
sarah-chitty-1 Nov 9, 2026
086851d
explorer: an always_on wake's closing text is labelled a private clos…
sarah-chitty-1 Nov 9, 2026
dc9e098
always_on: drop the date line that opened every wake
sarah-chitty-1 Nov 9, 2026
012f4c4
always_on: the per-wake reminder is off by default; stay_quiet and th…
sarah-chitty-1 Nov 9, 2026
f04fdf2
always_on: a failure after the conversation is made still closes it a…
sarah-chitty-1 Nov 9, 2026
8008ee8
triggers: a waiting fire goes on when a frozen clock is advanced to i…
sarah-chitty-1 Nov 9, 2026
974a6ab
triggers: a contact's reply that notifies the agent runs side by side…
sarah-chitty-1 Nov 10, 2026
aebf480
always_on: a contact's time limit is the one it was registered with; …
sarah-chitty-1 Nov 12, 2026
7dda611
verifiers: always_on_agent_verifier grades an always_on step's prompt…
sarah-chitty-1 Nov 12, 2026
ad2807c
Merge origin/main into sarahchitty/fd-5364-always-on-step
sarah-chitty-1 Nov 12, 2026
4835f14
always_on: a read whose time passed during the agent's turn waits unt…
sarah-chitty-1 Nov 12, 2026
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
3 changes: 2 additions & 1 deletion src/agent_env/env/gateway/clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ def set_time(self, virtual_time: str, rate=1.0) -> dict:

def set_rate(self, rate) -> dict:
"""Change an armed clock's rate from its time at the call, keeping t0 and generation as an advance does, so time
triggers keep their marks and a rate change can't step the clock back by a time read a moment earlier."""
triggers keep their marks and a rate change can't step the clock back by a time read a moment earlier; 0 freezes
the clock."""
r = _parse_rate(rate)
with self._lock:
if not self._armed or self._t0 is None:
Expand Down
25 changes: 21 additions & 4 deletions src/agent_env/env/gateway/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def data_plane_load_timeout_s(size_bytes: int | None) -> int:
EXT_STATE_URI = "urn:agentenv:state/v1"
EXT_CHANGES_URI = "urn:agentenv:changes/v1"
EXT_NOTIFICATIONS_URI = "urn:agentenv:notifications/v1"
EXT_USER_CHAT_URI = "urn:agentenv:user-chat/v1"
EXT_WEBSITE_URI = "urn:agentenv:website/v1"
EXT_BROWSER_URI = "urn:agentenv:browser/v1"
EXT_COMPUTER_APP_URI = "urn:agentenv:computer-app/v1"
Expand Down Expand Up @@ -139,9 +140,11 @@ def sources(names: list[str]) -> str:
TRIGGER_NEXT_EVENT_AT = "next_event_at"
TRIGGER_NOTIFY = "notify"
TRIGGER_WEBSITE_OP = "website_op"
# state reports each fire in flight (running, waits) and each executor call in progress (executor_calls).
TRIGGER_FIRES_IN_STATE = "fires_in_state"
TRIGGER_FEATURES = (TRIGGER_NAMED_EXECUTORS, TRIGGER_PROVOKING_ROLES, TRIGGER_ACTION_DELAY, TRIGGER_EXECUTOR_HOURS,
TRIGGER_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NEXT_EVENT_AT,
TRIGGER_NOTIFY, TRIGGER_WEBSITE_OP)
TRIGGER_NOTIFY, TRIGGER_WEBSITE_OP, TRIGGER_FIRES_IN_STATE)

# The one `source` a /step call may name instead of step_api, with the id of the hook artifact that made it.
STEP_SOURCE_POST_TURN_HOOK = "post_turn_hook"
Expand Down Expand Up @@ -230,21 +233,22 @@ def random_mcp_server_name() -> str:
},
{
"uri": EXT_TRIGGERS_URI,
"description": "Register (additive add-by-id), remove, clear, or read (state) typed Action/State triggers + the firing log (harness control path).",
"description": "Register (additive add-by-id), remove, clear, or read (state) typed Action/State triggers + the firing log (harness control path). config sets the engine's opt-in setting concurrent_fires (a repeat trigger whose actions are all contact replies (nl) or notifications (notify) runs its fires side by side instead of queueing).",
"params": {"endpoint": "/triggers/register", "features": list(TRIGGER_FEATURES), "methods": {
"register": {"method": "POST", "endpoint": "/triggers/register", "request": {"type": "object", "properties": {"watch_roles": {"type": "array", "items": {"type": "string"}}, "executor": {"type": "object"}, "executors": {"type": "object"}, "triggers": {"type": "array"}}, "required": ["triggers"]}},
"config": {"method": "POST", "endpoint": "/triggers/config", "request": {"type": "object", "properties": {"concurrent_fires": {"type": "boolean"}}}},
"remove": {"method": "POST", "endpoint": "/triggers/remove", "request": {"type": "object", "properties": {"ids": {"type": "array", "items": {"type": "string"}}}, "required": ["ids"]}},
"clear": {"method": "POST", "endpoint": "/triggers/clear", "request": {"type": "object", "properties": {}}},
"state": {"method": "GET", "endpoint": "/triggers/state", "request": {"type": "object", "properties": {}}}}},
"required": False,
},
{
"uri": EXT_CLOCK_URI,
"description": "Virtual clock (urn:agentenv:clock/v1): read the gateway's virtual time (wall-clock-derived, rate-scaled) via get_time; set_time/set_rate/advance/clear are the harness control path, each changing the clock's opaque version and, given wait_followers_s, answering 504 naming the followers that don't show it in time (set_rate keeps t0, as advance does); a follower follows it through watch, a long poll naming the version it shows and held up to hold_s; state's followers block is diagnostic, not part of the contract.",
"description": "Virtual clock (urn:agentenv:clock/v1): read the gateway's virtual time (wall-clock-derived, rate-scaled) via get_time; set_time/set_rate/advance/clear are the harness control path, each changing the clock's opaque version and, given wait_followers_s, answering 504 naming the followers that don't show it in time (set_rate keeps t0, as advance does, so time triggers keep their marks; rate 0 freezes the clock); a follower follows it through watch, a long poll naming the version it shows and held up to hold_s; state's followers block is diagnostic, not part of the contract.",
"params": {"endpoint": "/clock/time", "methods": {
"get_time": {"method": "GET", "endpoint": "/clock/time"},
"set_time": {"method": "PUT", "endpoint": "/clock/set-time", "request": {"type": "object", "properties": {"virtual_time": {"type": "string"}, "virtual_seconds_per_real_second": {"type": "number"}, **_WAIT_FOLLOWERS}, "required": ["virtual_time"]}},
"set_rate": {"method": "PUT", "endpoint": "/clock/set-rate", "request": {"type": "object", "properties": {"virtual_seconds_per_real_second": {"type": "number"}, **_WAIT_FOLLOWERS}, "required": ["virtual_seconds_per_real_second"]}},
"set_rate": {"method": "PUT", "endpoint": "/clock/set-rate", "request": {"type": "object", "properties": {"virtual_seconds_per_real_second": {"type": "number", "minimum": 0}, **_WAIT_FOLLOWERS}, "required": ["virtual_seconds_per_real_second"]}},
"advance": {"method": "PUT", "endpoint": "/clock/advance", "request": {"type": "object", "properties": {"virtual_time": {"type": "string"}, **_WAIT_FOLLOWERS}, "required": ["virtual_time"]}},
"clear": {"method": "POST", "endpoint": "/clock/clear", "request": {"type": "object", "properties": {**_WAIT_FOLLOWERS}}},
"state": {"method": "GET", "endpoint": "/clock/state", "request": {"type": "object", "properties": {}}},
Expand Down Expand Up @@ -295,4 +299,17 @@ def random_mcp_server_name() -> str:
"clear": {"method": "POST", "endpoint": "/notifications/clear", "request": {"type": "object", "properties": {}}}}},
"required": False,
},
{
"uri": EXT_USER_CHAT_URI,
"description": "User chat (urn:agentenv:user-chat/v1): the message_user tool, for the roles config names, is how an always-on agent reaches its user; each message is queued with its virtual time until the harness takes it. With stay_quiet the roles also get stay_quiet, which sends nothing and is recorded for the next take. Built in: role rules don't hide them from a configured role.",
"params": {"endpoint": "/user_chat/state", "methods": {
"config": {"method": "POST", "endpoint": "/user_chat/config", "request": {"type": "object", "properties": {
"roles": {"type": "array", "items": {"type": "string"}}, "user_name": {"type": "string"},
"stay_quiet": {"type": "boolean"}}}},
"state": {"method": "GET", "endpoint": "/user_chat/state", "request": {"type": "object", "properties": {}}},
"take": {"method": "POST", "endpoint": "/user_chat/take", "request": {"type": "object", "properties": {
"take_id": {"type": "string"}, "from": {"type": "string"}}}},
"clear": {"method": "POST", "endpoint": "/user_chat/clear", "request": {"type": "object", "properties": {}}}}},
"required": False,
},
]
119 changes: 116 additions & 3 deletions src/agent_env/env/gateway/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
)
from .triggers import TriggerEngine, TriggerError
from .notifications import CHECK_TOOL_NAMES, NotificationError, Notifications, build_check_tools
from .user_chat import MESSAGE_USER_TOOL_NAME, UserChat, UserChatError, build_user_chat_tool
from .user_chat import STAY_QUIET_TOOL_NAME, build_stay_quiet_tool

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -228,6 +230,7 @@ async def list_website_urls() -> list[str]:
self._clock = Clock()
self._clock_followers = ClockFollowers(lambda: self._clock, clock_followers or {})
self._notifications = Notifications(lambda: self._clock)
self._user_chat = UserChat(lambda: self._clock)
self._env_get_time_url_cache: str | None = None
self._install_role_filters()

Expand Down Expand Up @@ -322,6 +325,10 @@ async def triggers_register(request: Request) -> Response:
async def triggers_remove(request: Request) -> Response:
return await _trigger_write(request, self._trigger_engine.remove)

@self._mcp.custom_route("/triggers/config", methods=["POST"])
async def triggers_config(request: Request) -> Response:
return await _trigger_write(request, self._trigger_engine.config)

@self._mcp.custom_route("/triggers/clear", methods=["POST"])
async def triggers_clear(request: Request) -> Response:
return self._json_response(self._trigger_engine.clear(), 200)
Expand Down Expand Up @@ -437,6 +444,51 @@ async def notifications_clear(request: Request) -> Response:
self._unregister_check_tools()
return self._json_response(self._notifications.clear(), 200)

@self._mcp.custom_route("/user_chat/config", methods=["POST"])
async def user_chat_config(request: Request) -> Response:
try:
body = await request.json()
except Exception:
return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400)
if isinstance(body, dict) and body.get("roles"):
try:
await self._ensure_tools_discovered()
except Exception as e:
return self._json_response(
{"ok": False, "error": f"tool discovery has not completed ({type(e).__name__}); retry"}, 503)
if MESSAGE_USER_TOOL_NAME in self._tool_server_urls:
return self._json_response(
{"ok": False, "error": f"'{MESSAGE_USER_TOOL_NAME}' already registered by a backing server"}, 409)
try:
state = self._user_chat.configure(body)
except UserChatError as e:
return self._json_response({"ok": False, "error": str(e)}, 400)
if self._user_chat.enabled:
self._register_user_chat_tool()
else:
self._unregister_user_chat_tool()
return self._json_response(state, 200)

@self._mcp.custom_route("/user_chat/state", methods=["GET"])
async def user_chat_state(request: Request) -> Response:
return self._json_response(self._user_chat.state(), 200)

@self._mcp.custom_route("/user_chat/take", methods=["POST"])
async def user_chat_take(request: Request) -> Response:
try:
body = await request.json()
except Exception:
body = {}
try:
return self._json_response(self._user_chat.take(body), 200)
except UserChatError as e:
return self._json_response({"ok": False, "error": str(e)}, 400)

@self._mcp.custom_route("/user_chat/clear", methods=["POST"])
async def user_chat_clear(request: Request) -> Response:
self._unregister_user_chat_tool()
return self._json_response(self._user_chat.clear(), 200)

@self._mcp.custom_route("/trajectory", methods=["GET"])
async def env_trajectory(request: Request) -> Response:
return self._serve_trajectory()
Expand Down Expand Up @@ -563,9 +615,18 @@ def _listen_socket(self, family: int, host: str) -> socket.socket:
def _is_disabled(self, role: str, name: str) -> bool:
"""Walk the rule chain (role first, then WILDCARD) returning the first concrete True/False; default False.
A tool listed hidden by default skips "*"-tool rules: only rules naming it apply, and with none it is hidden.
The scheduled-check tools exist only for the roles the notifications config names, whatever the rules say."""
The scheduled-check tools exist only for the roles the notifications config names, whatever the rules say.
A role the user-chat config names always has message_user, get_time and the scheduled-check tools the
notifications config gives it, whatever the rules say."""
if name in CHECK_TOOL_NAMES and name not in self._tool_server_urls and role not in self._notifications.roles:
return True
if name == MESSAGE_USER_TOOL_NAME and name not in self._tool_server_urls:
return role not in self._user_chat.roles # built in: role rules don't hide it from a configured role
if (name == GET_TIME_TOOL_NAME or name in CHECK_TOOL_NAMES) and name not in self._tool_server_urls \
and role in self._user_chat.roles:
return False
if name == STAY_QUIET_TOOL_NAME and name not in self._tool_server_urls:
return not (self._user_chat.stay_quiet and role in self._user_chat.roles) # opt-in, like message_user
hidden = name in self._hidden_by_default
for r in (role, WILDCARD):
rules = self._role_rules.get(r, {})
Expand Down Expand Up @@ -654,12 +715,15 @@ async def _change_clock(self, change, wait_s: float = 0.0) -> tuple[dict, list[s
"""Every change of the clock goes through here. ``change`` applied, then: the time triggers re-evaluated when it
moved time on the current arm (it kept the clock's generation, as an advance or a rate change does; a re-arm or
clear re-anchors them by its new one), every held watch answered, and, given ``wait_s``, a wait for each declared
follower to show the new version. The state ``change`` left, with the triggers it made ``due``, and the
follower to show the new version. Either way, the fires waiting out virtual time are woken to read the clock
again (clock_advanced does it too). The state ``change`` left, with the triggers it made ``due``, and the
followers still behind."""
generation = self._clock.generation
state = change()
if self._clock.generation == generation:
state = {**state, "due": self._trigger_engine.clock_advanced()}
else:
self._trigger_engine.clock_changed()
self._clock_followers.changed()
return state, await self._clock_followers.stale_after(state["version"], wait_s) if wait_s else []

Expand Down Expand Up @@ -1036,7 +1100,8 @@ async def _discover_and_register_tools(self, sessions: dict[str, ClientSession])
# clock and the checks refuse to turn on over it
sources: dict[str, list[str]] = {
tool.name: ["gateway"] for tool in self._server_tools.get("gateway", [])
if tool.name != GET_TIME_TOOL_NAME and tool.name not in CHECK_TOOL_NAMES}
if tool.name != GET_TIME_TOOL_NAME and tool.name not in CHECK_TOOL_NAMES
and tool.name != MESSAGE_USER_TOOL_NAME and tool.name != STAY_QUIET_TOOL_NAME}
for server, tools in listed:
for tool in tools:
sources.setdefault(tool.name, []).append(server.name)
Expand Down Expand Up @@ -1330,6 +1395,54 @@ def _unregister_check_tools(self) -> None:
self._server_tools.pop("gateway", None)
self._trigger_engine.invalidate_readonly_cache()

def _register_user_chat_tool(self) -> None:
"""Expose message_user to the roles the user-chat config names (rebuilt so it names the user)."""
tools = self._mcp._tool_manager._tools
if MESSAGE_USER_TOOL_NAME in self._tool_server_urls:
return
tool = build_user_chat_tool(self._user_chat, self._create_arg_model_from_schema)
tools[MESSAGE_USER_TOOL_NAME] = tool
mirrored = [t for t in self._server_tools.get("gateway", []) if t.name != MESSAGE_USER_TOOL_NAME]
mirrored.append(MCPTool(name=tool.name, description=tool.description, inputSchema=tool.parameters,
annotations=tool.annotations))
self._server_tools["gateway"] = mirrored
self._sync_stay_quiet_tool()
self._trigger_engine.invalidate_readonly_cache()

def _sync_stay_quiet_tool(self) -> None:
"""stay_quiet exists only while the user-chat config turns it on (opt-in); otherwise it is absent."""
tools = self._mcp._tool_manager._tools
if STAY_QUIET_TOOL_NAME in self._tool_server_urls:
return
mirrored = [t for t in self._server_tools.get("gateway", []) if t.name != STAY_QUIET_TOOL_NAME]
if self._user_chat.stay_quiet:
tool = build_stay_quiet_tool(self._user_chat, self._create_arg_model_from_schema)
tools[STAY_QUIET_TOOL_NAME] = tool
mirrored.append(MCPTool(name=tool.name, description=tool.description, inputSchema=tool.parameters,
annotations=tool.annotations))
else:
tools.pop(STAY_QUIET_TOOL_NAME, None)
if mirrored:
self._server_tools["gateway"] = mirrored
else:
self._server_tools.pop("gateway", None)

def _unregister_user_chat_tool(self) -> None:
"""Remove message_user and stay_quiet when the chat is turned off or cleared; a backing server's are left."""
tools = self._mcp._tool_manager._tools
if MESSAGE_USER_TOOL_NAME in self._tool_server_urls:
return
tools.pop(MESSAGE_USER_TOOL_NAME, None)
if STAY_QUIET_TOOL_NAME not in self._tool_server_urls:
tools.pop(STAY_QUIET_TOOL_NAME, None)
remaining = [t for t in self._server_tools.get("gateway", [])
if t.name not in (MESSAGE_USER_TOOL_NAME, STAY_QUIET_TOOL_NAME)]
if remaining:
self._server_tools["gateway"] = remaining
else:
self._server_tools.pop("gateway", None)
self._trigger_engine.invalidate_readonly_cache()

def _create_and_register_proxy_tool(self, server_url: str, tool: MCPTool) -> None:
self._mcp._tool_manager._tools[tool.name] = self._proxy_tool(server_url, tool)

Expand Down
Loading
Loading