Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 26 additions & 1 deletion src/agent_env/env/gateway/triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,12 @@
_ECHO_ID_RE = re.compile(r"(?:^id|Id|_id)$") # id, fileId, documentId, email_id, …
_ECHO_VALUE_MAX = 200 # chars; an allowlisted scalar longer than this is a payload wearing an id's name
_ECHO_DIGEST_CHARS = 16
_ECHO_ARGS_MAX_CHARS = 4096 # JSON chars; a provoking call's echo past this is recorded as its args' shape alone
_ECHO_ARGS_MAX_VALUES = 256 # a provoking call's args holding more values are shaped without building their echo

# Firing-log event vocabulary (the GET /triggers/state contract); _emit rejects anything not here.
# `provoking` is {tool, role, args} for a tool call, {source: "website", app, method, path, status, role}
# for a website request, or {source: "clock"} for a time trigger.
_EVENT_KINDS = frozenset({
"added", "removed", # registration lifecycle
"detected", # a trigger's condition matched
Expand Down Expand Up @@ -307,6 +311,27 @@ def _echo_args(value: Any, key: str | None = None) -> Any:
return _shape(value)


def _echo_call_args(arguments: dict) -> Any:
"""A provoking call's args as its events carry them: `_echo_args`, or only their shape when that echo is too large."""
if _holds_more_than(arguments, _ECHO_ARGS_MAX_VALUES):
return _shape(arguments)
echo = _echo_args(arguments)
return echo if len(json.dumps(echo)) <= _ECHO_ARGS_MAX_CHARS else _shape(arguments)


def _holds_more_than(value: Any, limit: int) -> bool:
"""Whether ``value`` nests more than ``limit`` values, counting no further than ``limit``."""
pending, count = [value], 1
while pending:
node = pending.pop()
children = node.values() if isinstance(node, dict) else node if isinstance(node, list) else ()
count += len(children)
if count > limit:
return True
pending.extend(children)
return False


def _strings(value: Any) -> list[str]:
"""Every string anywhere in an args structure, so the two placeholder scans share one walk."""
if isinstance(value, dict):
Expand Down Expand Up @@ -899,7 +924,7 @@ def on_tool_call(self, role: str, name: str, arguments: dict,
return [], None
data = _result_data(result)
ctx = {"args": arguments, "result": data}
provoking = {"tool": name, "role": role}
provoking = {"tool": name, "role": role, "args": _echo_call_args(arguments)}
# Before detection, so the call that sets off a deadline's anchor can't also be the call that cancels it.
for tid, trig in provokable:
when = trig["spec"]["when"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ async def test_a_deadline_fires_on_silence_and_a_reply_cancels_it(items_env):
assert bob.instructions == ["ESCALATE-1"]
[cancelled] = [e for e in state["events"] if e["kind"] == "cancelled"]
assert (cancelled["trigger_id"], cancelled["provoking"]["role"]) == ("deadline-2", "default")
assert cancelled["provoking"]["args"] == {"item": {"redacted": {"type": "str", "len": 7}}}


@pytest.mark.asyncio
Expand Down
31 changes: 30 additions & 1 deletion tst/unit/env/gateway/triggers_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,14 +752,43 @@ async def test_a_matching_call_after_the_anchor_cancels_the_deadline(engine, vir
engine.on_tool_call("default", "slack_send_message", {"channel_id": "D1"}, _result())
assert engine.state()["triggers"][1]["status"] == "cancelled"
[cancelled] = [e for e in engine._events if e["kind"] == "cancelled"]
assert cancelled["provoking"] == {"tool": "slack_send_message", "role": "default"}
assert cancelled["provoking"] == {"tool": "slack_send_message", "role": "default", "args": {"channel_id": "D1"}}

virtual_time.advance(3600)
engine._eval_time_triggers()
await asyncio.sleep(0.1)
assert engine._triggers["deadline"]["status"] == "cancelled" and engine._triggers["deadline"]["fire_count"] == 0


@pytest.mark.asyncio
async def test_each_detection_names_its_own_calls_ids_but_not_its_content(engine):
engine.register({"triggers": [
{"id": "dm", "when": {"type": "action", "tool": "slack_send_message", "repeat": True}, "actions": []}]})
engine.on_tool_call("default", "slack_send_message", {"channel_id": "D1", "message": "approve $2,400?"}, _result())
engine.on_tool_call("default", "slack_send_message", {"channel_id": "U_1", "message": "ok"}, _result()) # queued
await asyncio.sleep(0.1)

detected = [e for e in engine._events if e["kind"] == "detected"]
assert [(e["provoking"]["args"], e.get("queued")) for e in detected] == [
({"channel_id": "D1", "message": {"redacted": {"type": "str", "len": 15}}}, None),
({"channel_id": "U_1", "message": {"redacted": {"type": "str", "len": 2}}}, 1)]
assert engine._triggers["dm"]["fire_count"] == 2


@pytest.mark.asyncio
async def test_args_too_large_to_echo_are_recorded_as_their_shape_and_still_fire(engine):
engine.register({"triggers": [{"id": "any", "when": {"type": "action", "tool": "t", "repeat": True}, "actions": []}]})
many_values = {"rows": [{"row_id": f"r{i}"} for i in range(10_000)], "name": "x"}
few_long_ids = {f"k{i}_id": "x" * 199 for i in range(25)}
for args in (many_values, few_long_ids):
engine.on_tool_call("default", "t", args, _result())
await asyncio.sleep(0.1)

assert [e["provoking"]["args"] for e in engine._events if e["kind"] == "detected"] == [
{"redacted": {"type": "dict", "len": 2}}, {"redacted": {"type": "dict", "len": 25}}]
assert engine._triggers["any"]["fire_count"] == 2


@pytest.mark.asyncio
async def test_a_deadline_lists_the_executor_roles_that_cancel_it(engine, virtual_time):
engine.register(_deadline(roles=["bob"]))
Expand Down
Loading