Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 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
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
15 changes: 15 additions & 0 deletions src/agent_env/env/gateway/clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@ def advance(self, virtual_time: str) -> dict:
self._lived += jump
return self.state()

def set_rate(self, rate) -> dict:
"""Change the rate on the current arm from now on, keeping the virtual time, t0 and generation, so time
triggers keep their marks (a re-arm with set_time would re-anchor them); 0 freezes the clock."""
r = _parse_rate(rate)
with self._lock:
if not self._armed or self._t0 is None:
raise ClockError("clock is not armed")
current = _advance(self._t0, (self._now() - self._anchor) * self._rate + self._ahead)
if current > _MAX_T0 - timedelta(seconds=_OVERFLOW_HORIZON_S * r):
raise ClockError(f"rate {r:g} would overflow datetime near the max from {_iso(current)}")
self._bank()
self._ahead = (current - self._t0).total_seconds()
self._rate = r
return self.state()

def clear(self) -> dict:
"""Disarm (idempotent). Afterwards reads 404."""
with self._lock:
Expand Down
24 changes: 21 additions & 3 deletions src/agent_env/env/gateway/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,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 @@ -130,9 +131,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 @@ -207,21 +210,23 @@ 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 settings: concurrent_fires (a repeat trigger whose actions are all contact replies (nl) or notifications (notify) runs its fires side by side instead of queueing) and executor_timeout_seconds (the time limit of an executor that sets none).",
"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"}, "executor_timeout_seconds": {"type": "number", "exclusiveMinimum": 0}}}},
"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/advance/clear are the harness control path.",
"description": "Virtual clock (urn:agentenv:clock/v1): read the gateway's virtual time (wall-clock-derived, rate-scaled) via get_time; set-time/advance/set_rate/clear are the harness control path (set_rate changes the rate on the current arm, keeping time triggers' marks; 0 freezes the clock).",
"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"}}, "required": ["virtual_time"]}},
"advance": {"method": "PUT", "endpoint": "/clock/advance", "request": {"type": "object", "properties": {"virtual_time": {"type": "string"}}, "required": ["virtual_time"]}},
"set_rate": {"method": "PUT", "endpoint": "/clock/rate", "request": {"type": "object", "properties": {"virtual_seconds_per_real_second": {"type": "number", "minimum": 0}}, "required": ["virtual_seconds_per_real_second"]}},
"clear": {"method": "POST", "endpoint": "/clock/clear", "request": {"type": "object", "properties": {}}},
"state": {"method": "GET", "endpoint": "/clock/state", "request": {"type": "object", "properties": {}}}}},
"required": False,
Expand Down Expand Up @@ -270,4 +275,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,
},
]
131 changes: 129 additions & 2 deletions src/agent_env/env/gateway/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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 @@ -201,6 +203,7 @@ async def list_website_urls() -> list[str]:
)
self._clock = Clock()
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 @@ -295,6 +298,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 All @@ -316,6 +323,7 @@ async def clock_set_time(request: Request) -> Response:
state = self._clock.set_time(body.get("virtual_time"), body.get("virtual_seconds_per_real_second"))
except ClockError as e:
return self._json_response({"ok": False, "error": str(e)}, 400)
self._trigger_engine.clock_changed()
self._register_get_time_tool()
return self._json_response(state, 200)

Expand All @@ -333,10 +341,26 @@ async def clock_advance(request: Request) -> Response:
return self._json_response({"ok": False, "error": str(e)}, 400)
return self._json_response({**state, "due": self._trigger_engine.clock_advanced()}, 200)

@self._mcp.custom_route("/clock/rate", methods=["PUT"])
async def clock_rate(request: Request) -> Response:
try:
body = await request.json()
except Exception:
return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400)
if not isinstance(body, dict):
return self._json_response({"ok": False, "error": "body must be a JSON object"}, 400)
try:
state = self._clock.set_rate(body.get("virtual_seconds_per_real_second"))
except ClockError as e:
return self._json_response({"ok": False, "error": str(e)}, 400)
self._trigger_engine.clock_changed()
return self._json_response(state, 200)

@self._mcp.custom_route("/clock/clear", methods=["POST"])
async def clock_clear(request: Request) -> Response:
self._unregister_get_time_tool()
state = self._clock.clear()
self._trigger_engine.clock_changed()
return self._json_response(state, 200)

@self._mcp.custom_route("/clock/time", methods=["GET"])
Expand Down Expand Up @@ -408,6 +432,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 @@ -524,9 +593,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 @@ -937,7 +1015,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 @@ -1231,6 +1310,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