Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
1ed4811
feat(prompt_agent): run a multi-turn conversation from one env event …
sarah-chitty-1 Oct 19, 2026
9dc9ddf
fix(prompt_agent): await a due trigger on each env by env and trigger id
sarah-chitty-1 Oct 20, 2026
974d115
fix(prompt_agent): a running clock that reaches the mark before the a…
sarah-chitty-1 Oct 20, 2026
e6b4d0e
fix(prompt_agent): a recurring time trigger still mid-fire keeps its …
sarah-chitty-1 Oct 20, 2026
c42e853
fix(prompt_agent): the message an agent trigger ends the conversation…
sarah-chitty-1 Oct 20, 2026
810b448
fix(gateway): PUT /clock/advance answers a body that isn't a JSON obj…
sarah-chitty-1 Oct 20, 2026
8c1a11a
fix(prompt_agent): a mark whose trigger didn't settle is not chosen a…
sarah-chitty-1 Oct 20, 2026
4245d33
Merge branch 'main' into sarahchitty/prompt-advance-to-next-event
sarah-chitty-1 Oct 20, 2026
eda2fe5
review: the jump to end_time refuses a re-arm over a relative or recu…
sarah-chitty-1 Oct 21, 2026
fa9b7f1
Merge branch 'main' into sarahchitty/prompt-advance-to-next-event
sarah-chitty-1 Oct 26, 2026
b8f659e
review: prompt_agent.post_turn_hooks replace between_turns, and every…
sarah-chitty-1 Oct 26, 2026
d2241e1
review: clock advance returns what came due; triggers state gains nex…
sarah-chitty-1 Oct 26, 2026
d5af210
review: the next-env-event example hook, which runs a conversation fr…
sarah-chitty-1 Oct 26, 2026
5903ae6
Merge branch 'main' into sarahchitty/prompt-advance-to-next-event
sarah-chitty-1 Oct 26, 2026
fd2dbf9
review: the user-side test pins the user sim's output schema itself, …
sarah-chitty-1 Oct 26, 2026
8edf926
Merge branch 'main' into sarahchitty/prompt-advance-to-next-event
sarah-chitty-1 Oct 26, 2026
59b6f25
review: an advance past the close of an executor's hours waits for th…
sarah-chitty-1 Oct 26, 2026
e89a539
feat(gateway): wakeups — checks an agent schedules for itself, and no…
sarah-chitty-1 Oct 27, 2026
47c2d38
Merge branch 'sarahchitty/agent-wakeups' into sarahchitty/agent-wakeu…
sarah-chitty-1 Oct 27, 2026
d4847a8
feat(examples): agent-wakeups hook — wake the agent only for its own …
sarah-chitty-1 Oct 27, 2026
b26425c
Merge main into sarahchitty/prompt-advance-to-next-event
sarah-chitty-1 Oct 27, 2026
8f9f624
fix(gateway): wakeups — count repeats instead of stepping, judge due …
sarah-chitty-1 Oct 27, 2026
58d3691
Merge branch 'sarahchitty/agent-wakeups' into sarahchitty/agent-wakeu…
sarah-chitty-1 Oct 27, 2026
d4337c4
fix(triggers): a state check still running counts as busy; the instal…
sarah-chitty-1 Oct 27, 2026
42b89e1
Merge commit 'd4337c4' into sarahchitty/agent-wakeups-hook
sarah-chitty-1 Oct 27, 2026
27fd61b
fix(examples): agent-wakeups waits for a state check the agent's turn…
sarah-chitty-1 Oct 27, 2026
0878659
fix(gateway): wakeups echo durations as ISO 8601 (PT3H), the form the…
sarah-chitty-1 Oct 27, 2026
f3a2635
Merge branch 'sarahchitty/agent-wakeups' into sarahchitty/agent-wakeu…
sarah-chitty-1 Oct 27, 2026
a38c900
Merge branch 'main' into sarahchitty/prompt-advance-to-next-event
sarah-chitty-1 Oct 27, 2026
5832188
fix(post-turn-hooks): register a hook's module while it loads, so dat…
sarah-chitty-1 Oct 27, 2026
f551cc5
Merge commit '5832188' into sarahchitty/agent-wakeups-hook
sarah-chitty-1 Oct 27, 2026
e5ea065
Merge branch 'main' of github.com:scaleapi/agentenv-framework into sa…
sarah-chitty-1 Oct 27, 2026
0965e7c
fix(gateway): wakeups — turning checks off cancels the pending ones; …
sarah-chitty-1 Oct 27, 2026
7f229d5
Merge branch 'sarahchitty/agent-wakeups' into sarahchitty/agent-wakeu…
sarah-chitty-1 Oct 27, 2026
ed0c5aa
Merge branch 'main' of github.com:scaleapi/agentenv-framework into sa…
sarah-chitty-1 Oct 27, 2026
ad5661f
Merge branch 'sarahchitty/agent-wakeups' into sarahchitty/agent-wakeu…
sarah-chitty-1 Oct 27, 2026
9007d27
refactor(gateway): rename wakeups to notifications (urn:agentenv:noti…
sarah-chitty-1 Oct 29, 2026
5f847fa
fix(gateway): schedule_check returns what the gateway decided, not th…
sarah-chitty-1 Oct 29, 2026
01f81d4
feat(gateway): notifications have a recipient; checks are off unless …
sarah-chitty-1 Oct 29, 2026
5cef82a
feat(prompt_agent): deliver what came due on notifications/v1 at the …
sarah-chitty-1 Oct 29, 2026
2faab84
fix(notifications): a retried take gets the same batch back; name the…
sarah-chitty-1 Oct 29, 2026
22c5d77
refactor(notifications): every item take returns has one shape
sarah-chitty-1 Oct 29, 2026
eea2c95
fix(notifications): schedule_check states its real rules; time errors…
sarah-chitty-1 Oct 29, 2026
f0b5b02
fix(notifications): say how config applies settings; clear resets the…
sarah-chitty-1 Oct 29, 2026
17054da
Merge branch 'sarahchitty/agent-wakeups' into sarahchitty/agent-wakeu…
sarah-chitty-1 Oct 29, 2026
a73ef14
fix(examples): agent-wakeups moves the clock and prompt_agent deliver…
sarah-chitty-1 Oct 29, 2026
51229ba
refactor(examples): rename agent-wakeups to advance-to-next-event
sarah-chitty-1 Oct 29, 2026
815a4bb
Merge branch 'main' of github.com:scaleapi/agentenv-framework into sa…
sarah-chitty-1 Oct 29, 2026
219dafa
Merge sarahchitty/agent-wakeups (with main 0.9.1387) into sarahchitty…
sarah-chitty-1 Oct 29, 2026
d76448f
fix(triggers): a notify whose text resolves to nothing queues no noti…
sarah-chitty-1 Oct 29, 2026
1ae2fb0
feat(examples): advance-to-next-event ships an example task
sarah-chitty-1 Oct 29, 2026
1c0b203
Merge branch 'sarahchitty/agent-wakeups' into sarahchitty/agent-wakeu…
sarah-chitty-1 Oct 29, 2026
256e10f
fix(examples): advance-to-next-event skips unarmed clocks, ends when …
sarah-chitty-1 Oct 29, 2026
70b7095
refactor(prompt_agent): drop time_zone; delivered lines are labelled …
sarah-chitty-1 Oct 30, 2026
0988a8e
Merge remote-tracking branch 'origin/main' into sarahchitty/agent-wak…
sarah-chitty-1 Oct 30, 2026
c63445c
Merge branch 'sarahchitty/agent-wakeups' into sarahchitty/agent-wakeu…
sarah-chitty-1 Oct 30, 2026
1220033
refactor(examples): advance-to-next-event labels the person's message…
sarah-chitty-1 Oct 30, 2026
6d370b3
fix(examples): advance-to-next-event reads only its own conversation'…
sarah-chitty-1 Oct 30, 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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ hello = "agent_env.examples"
echo-mcp = "agent_env.examples"
mail-mcp = "agent_env.examples"
next-env-event = "agent_env.examples"
advance-to-next-event = "agent_env.examples"

[project.urls]
Homepage = "https://www.agentenvframework.com"
Expand Down
19 changes: 18 additions & 1 deletion src/agent_env/env/gateway/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def data_plane_load_timeout_s(size_bytes: int | None) -> int:
EXT_STEP_URI = "urn:agentenv:step/v1"
EXT_STATE_URI = "urn:agentenv:state/v1"
EXT_CHANGES_URI = "urn:agentenv:changes/v1"
EXT_NOTIFICATIONS_URI = "urn:agentenv:notifications/v1"

# Rows per GET /changes page: the default, and the most a client may ask for.
CHANGES_PAGE_DEFAULT = 1000
Expand Down Expand Up @@ -122,8 +123,10 @@ def sources(names: list[str]) -> str:
TRIGGER_WEBSITE = "website"
TRIGGER_EXECUTOR_HOURS_TIME_ZONE = "executor_hours_time_zone"
TRIGGER_NEXT_EVENT_AT = "next_event_at"
TRIGGER_NOTIFY = "notify"
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_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NEXT_EVENT_AT,
TRIGGER_NOTIFY)

# 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 @@ -244,4 +247,18 @@ def random_mcp_server_name() -> str:
"limit": {"type": "integer", "minimum": 1, "maximum": CHANGES_PAGE_MAX}}}}}},
"required": False,
},
{
"uri": EXT_NOTIFICATIONS_URI,
"description": "Notifications (urn:agentenv:notifications/v1): what comes due for a role between its turns, on the virtual clock, each for one recipient (`to`): checks a role scheduled for itself (schedule_check, which only the roles config names see) and notifications triggers sent (the notify action, to the default role unless it names another). state says when the next is due; take hands a recipient each due one once (harness control path), every item one shape: id, to, at, times, source (scheduled_check or trigger), source_id, channel (the label the agent sees) and parts. config replaces all settings (omitted fields reset to defaults, so one without roles turns checks off) and leaves existing checks as they are unless it drops their role: lowering max_active leaves extra active checks in place, and raising min_interval leaves existing repeats unchanged. clear resets the settings to their defaults and drops every check and notification. Unrelated to A2A push notifications.",
"params": {"endpoint": "/notifications/state", "methods": {
"config": {"method": "POST", "endpoint": "/notifications/config", "request": {"type": "object", "properties": {
"roles": {"type": "array", "items": {"type": "string"}}, "max_active": {"type": "integer", "minimum": 1},
"min_interval": {"type": "string"}}}},
"state": {"method": "GET", "endpoint": "/notifications/state", "request": {"type": "object", "properties": {
"to": {"type": "string"}}}},
"take": {"method": "POST", "endpoint": "/notifications/take", "request": {"type": "object", "properties": {
"to": {"type": "string"}, "until": {"type": "string"}}}},
"clear": {"method": "POST", "endpoint": "/notifications/clear", "request": {"type": "object", "properties": {}}}}},
"required": False,
},
]
119 changes: 115 additions & 4 deletions src/agent_env/env/gateway/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
from .get_time import GET_TIME_TOOL_NAME, build_get_time_tool
from .rest_proxy import ROLE_HEADER_BYTES, UpstreamResponse, fetch_child_card, forward, rewrite_child_card
from .triggers import TriggerEngine, TriggerError
from .notifications import CHECK_TOOL_NAMES, NotificationError, Notifications, build_check_tools

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -173,6 +174,7 @@ async def list_website_urls() -> list[str]:
)
)
self._clock = Clock()
self._notifications = Notifications(lambda: self._clock)
self._env_get_time_url_cache: str | None = None
self._install_role_filters()

Expand Down Expand Up @@ -314,6 +316,61 @@ async def clock_state(request: Request) -> Response:
st["env_get_time_url"] = await self._env_get_time_url()
return self._json_response(st, 200)

async def _notifications_write(request: Request, method) -> Response:
try:
body = await request.json()
except Exception:
return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400)
try:
return self._json_response(method(body), 200)
except NotificationError as e:
return self._json_response({"ok": False, "error": str(e)}, 400)

@self._mcp.custom_route("/notifications/config", methods=["POST"])
async def notifications_config(request: Request) -> Response:
try:
body = await request.json()
except Exception:
return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400)
# A config that names roles registers the tools, so the backing servers' tools must be known (or a later
# discovery would replace ours unseen) and none may have their names. One that turns checks off
# (roles: [], the default) registers nothing, so neither can block it.
if not (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)
taken = sorted(set(CHECK_TOOL_NAMES) & set(self._tool_server_urls))
if taken:
return self._json_response(
{"ok": False, "error": f"{taken} already registered by a backing server"}, 409)
try:
state = self._notifications.configure(body)
except NotificationError as e:
return self._json_response({"ok": False, "error": str(e)}, 400)
if self._notifications.enabled:
self._register_check_tools()
else:
self._unregister_check_tools()
return self._json_response(state, 200)

@self._mcp.custom_route("/notifications/state", methods=["GET"])
async def notifications_state(request: Request) -> Response:
try: # ?to=<role> for one recipient's; every recipient's without it
return self._json_response(self._notifications.state(request.query_params.get("to")), 200)
except NotificationError as e:
return self._json_response({"ok": False, "error": str(e)}, 400)

@self._mcp.custom_route("/notifications/take", methods=["POST"])
async def notifications_take(request: Request) -> Response:
return await _notifications_write(request, self._notifications.take)

@self._mcp.custom_route("/notifications/clear", methods=["POST"])
async def notifications_clear(request: Request) -> Response:
self._unregister_check_tools()
return self._json_response(self._notifications.clear(), 200)

@self._mcp.custom_route("/trajectory", methods=["GET"])
async def env_trajectory(request: Request) -> Response:
return self._serve_trajectory()
Expand Down Expand Up @@ -425,7 +482,10 @@ 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."""
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."""
if name in CHECK_TOOL_NAMES and name not in self._tool_server_urls and role not in self._notifications.roles:
return True
hidden = name in self._hidden_by_default
for r in (role, WILDCARD):
rules = self._role_rules.get(r, {})
Expand Down Expand Up @@ -901,9 +961,11 @@ async def _discover_and_register_tools(self, sessions: dict[str, ClientSession])
raise
logger.info(f"Found {len(tools_result.tools)} tool(s) from {server.name}")
listed.append((server, tools_result.tools))
# get_time is left out: a backing server's tool of that name wins, and the clock refuses to arm over it
# get_time and the scheduled-check tools are left out: a backing server's tool of that name wins, and the
# 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}
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}
for server, tools in listed:
for tool in tools:
sources.setdefault(tool.name, []).append(server.name)
Expand All @@ -923,6 +985,8 @@ async def _discover_and_register_tools(self, sessions: dict[str, ClientSession])
self._tools_discovered = True
if self._mirror_get_time_descriptor():
logger.info(f"Re-mirrored '{GET_TIME_TOOL_NAME}' descriptor after tool discovery")
if self._mirror_check_descriptors():
logger.info("Re-mirrored the scheduled-check tool descriptors after tool discovery")
# list_website_urls sits in _server_tools from construction, so a memo may predate the backing tools
self._trigger_engine.invalidate_readonly_cache()

Expand Down Expand Up @@ -1083,7 +1147,12 @@ async def _step_call_gateway_tool(self, tool_name: str, arguments: dict, role: s
})
try:
async with self._tool_call_guard():
result = await tool.run(arguments)
# bound as the MCP path binds it, so a tool that acts for its caller (schedule_check) reads the role
token = Caller(role=role, session=Caller.current().session).bind()
try:
result = await tool.run(arguments)
finally:
Caller.reset(token)
changelog_id = self._query_changelog_id()
if isinstance(result, CallToolResult):
response_data = result.model_dump(mode="json", exclude_none=True)
Expand Down Expand Up @@ -1148,6 +1217,48 @@ def _unregister_get_time_tool(self) -> None:
self._server_tools.pop("gateway", None)
self._trigger_engine.invalidate_readonly_cache()

def _register_check_tools(self) -> None:
"""Expose schedule_check and its siblings when a config names roles, rebuilt on each config so their
descriptions carry its max_active and min_interval."""
self._unregister_check_tools()
tools = self._mcp._tool_manager._tools
for tool in build_check_tools(self._notifications, self._create_arg_model_from_schema):
if tool.name not in self._tool_server_urls:
tools[tool.name] = tool
if self._mirror_check_descriptors():
self._trigger_engine.invalidate_readonly_cache()

def _mirror_check_descriptors(self) -> bool:
"""Mirror the registered scheduled-check tools into `_server_tools`, as `_mirror_get_time_descriptor` does for
get_time. Returns whether it appended."""
if not self._tools_discovered or not self._notifications.enabled:
return False
mirrored = self._server_tools.setdefault("gateway", [])
present = {t.name for t in mirrored}
added = False
for name in CHECK_TOOL_NAMES:
tool = self._mcp._tool_manager._tools.get(name)
if tool is None or name in present or name in self._tool_server_urls:
continue
mirrored.append(MCPTool(name=tool.name, description=tool.description,
inputSchema=tool.parameters, annotations=tool.annotations))
added = True
return added

def _unregister_check_tools(self) -> None:
"""Remove them when a task turns scheduled checks off or clears (idempotent); a backing server's are left."""
tools = self._mcp._tool_manager._tools
for name in CHECK_TOOL_NAMES:
if name not in self._tool_server_urls:
tools.pop(name, None)
remaining = [t for t in self._server_tools.get("gateway", []) if t.name not in CHECK_TOOL_NAMES
or t.name in self._tool_server_urls]
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