agentenv-framework-protocol 0.1.269__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,489 @@
1
+ """Deterministic implementation of ``urn:agentenv:triggers/v1``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import time
8
+ import uuid
9
+ from collections import OrderedDict, deque
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ import regex
15
+
16
+ _MAX_TRIGGERS = 256
17
+ _MAX_CONTEXTS = 1_024
18
+ _MAX_LOG_ENTRIES = 1_024
19
+ _MAX_TRIGGER_ID_LENGTH = 128
20
+ _MAX_CONTEXT_ID_LENGTH = 256
21
+ _MAX_ENV_ID_LENGTH = 256
22
+ _MAX_STATUS_LENGTH = 128
23
+ _MAX_ACTION_TEXT_LENGTH = 8_192
24
+ _MAX_REGEX_LENGTH = 4_096
25
+ _MAX_TRIGGER_SPEC_BYTES = 16 * 1_024
26
+ MAX_SOLVER_MESSAGE_LENGTH = 200_000
27
+ _DEFAULT_REGEX_BUDGET_SECONDS = 1.0
28
+ _MAX_PATTERN_ERROR_LENGTH = 80
29
+
30
+
31
+ class TriggerError(ValueError):
32
+ """Raised when a trigger registration or decision request is invalid."""
33
+
34
+ status_code = 400
35
+
36
+
37
+ class TriggerTimeoutError(TriggerError):
38
+ """Raised when regex evaluation exhausts a decision's shared time budget."""
39
+
40
+ status_code = 504
41
+
42
+
43
+ class _RegexBudget:
44
+ """One monotonic deadline shared by every regex in a decision."""
45
+
46
+ def __init__(self, seconds: float) -> None:
47
+ self.seconds = seconds
48
+ self.deadline = time.monotonic() + seconds
49
+
50
+ def remaining(self) -> float:
51
+ return self.deadline - time.monotonic()
52
+
53
+
54
+ def _validate_string_length(value: str, where: str, maximum: int) -> None:
55
+ if len(value) > maximum:
56
+ raise TriggerError(f"{where} must be at most {maximum} characters")
57
+
58
+
59
+ def _validate_specification_size(
60
+ specification: dict[str, Any], trigger_id: str
61
+ ) -> None:
62
+ """Reject an accepted trigger whose retained JSON representation is too large."""
63
+ try:
64
+ size = len(
65
+ json.dumps(specification, ensure_ascii=False, separators=(",", ":")).encode(
66
+ "utf-8"
67
+ )
68
+ )
69
+ except (TypeError, ValueError) as exc:
70
+ raise TriggerError(
71
+ f"trigger '{trigger_id}' specification must be JSON-compatible"
72
+ ) from exc
73
+ if size > _MAX_TRIGGER_SPEC_BYTES:
74
+ raise TriggerError(
75
+ f"trigger '{trigger_id}' specification must be at most "
76
+ f"{_MAX_TRIGGER_SPEC_BYTES} bytes"
77
+ )
78
+
79
+
80
+ def _validate_predicate(predicate: Any, where: str) -> None:
81
+ keys = ("regex", "equals", "exists")
82
+ if not isinstance(predicate, dict) or sum(key in predicate for key in keys) != 1:
83
+ raise TriggerError(f"{where}: predicate must have exactly one of {keys}")
84
+ if "regex" in predicate:
85
+ if not isinstance(predicate["regex"], str):
86
+ raise TriggerError(f"{where}: regex must be a string")
87
+ _validate_string_length(
88
+ predicate["regex"], f"{where}: regex", _MAX_REGEX_LENGTH
89
+ )
90
+ try:
91
+ regex.compile(predicate["regex"])
92
+ except regex.error as exc:
93
+ raise TriggerError(f"{where}: invalid regex: {exc}") from exc
94
+ if "exists" in predicate and not isinstance(predicate["exists"], bool):
95
+ raise TriggerError(f"{where}: exists must be a boolean")
96
+
97
+
98
+ def _matches_predicate(
99
+ predicate: dict[str, Any], value: Any, budget: _RegexBudget
100
+ ) -> bool:
101
+ if "regex" in predicate:
102
+ text = value if isinstance(value, str) else "" if value is None else str(value)
103
+ pattern = predicate["regex"]
104
+ remaining = budget.remaining()
105
+ display_pattern = (
106
+ pattern
107
+ if len(pattern) <= _MAX_PATTERN_ERROR_LENGTH
108
+ else f"{pattern[:_MAX_PATTERN_ERROR_LENGTH]}..."
109
+ )
110
+ if remaining <= 0:
111
+ raise TriggerTimeoutError(
112
+ "decide: regex budget of "
113
+ f"{budget.seconds}s exhausted before evaluating {display_pattern!r}"
114
+ )
115
+ try:
116
+ return regex.search(pattern, text, timeout=remaining) is not None
117
+ except TimeoutError as exc:
118
+ raise TriggerTimeoutError(
119
+ f"decide: regex {display_pattern!r} exceeded the "
120
+ f"{budget.seconds}s decision budget"
121
+ ) from exc
122
+ if "equals" in predicate:
123
+ return value == predicate["equals"]
124
+ present = value is not None and value != "" and value != []
125
+ return present == predicate["exists"]
126
+
127
+
128
+ @dataclass
129
+ class _ContextState:
130
+ fired: set[str] = field(default_factory=set)
131
+ last_turn: int | None = None
132
+
133
+
134
+ class TriggerEngine:
135
+ """Small, bounded stateful engine shared by every framework-backed agent."""
136
+
137
+ def __init__(
138
+ self,
139
+ *,
140
+ max_triggers: int = _MAX_TRIGGERS,
141
+ max_contexts: int = _MAX_CONTEXTS,
142
+ max_log_entries: int = _MAX_LOG_ENTRIES,
143
+ regex_budget_seconds: float = _DEFAULT_REGEX_BUDGET_SECONDS,
144
+ ) -> None:
145
+ if min(max_triggers, max_contexts, max_log_entries) < 1:
146
+ raise ValueError("trigger engine limits must be positive")
147
+ if (
148
+ isinstance(regex_budget_seconds, bool)
149
+ or not isinstance(regex_budget_seconds, (int, float))
150
+ or not math.isfinite(regex_budget_seconds)
151
+ or regex_budget_seconds <= 0
152
+ ):
153
+ raise ValueError("regex_budget_seconds must be a finite positive number")
154
+ self._max_triggers = max_triggers
155
+ self._max_contexts = max_contexts
156
+ self._regex_budget_seconds = float(regex_budget_seconds)
157
+ self._triggers: dict[str, dict[str, Any]] = {}
158
+ self._contexts: OrderedDict[str, _ContextState] = OrderedDict()
159
+ self._log: deque[dict[str, Any]] = deque(maxlen=max_log_entries)
160
+ self._sequence = 0
161
+
162
+ def register(self, payload: Any) -> dict[str, Any]:
163
+ if not isinstance(payload, dict):
164
+ raise TriggerError("registration body must be an object")
165
+ triggers = payload.get("triggers")
166
+ if not isinstance(triggers, list) or not triggers:
167
+ raise TriggerError("'triggers' must be a non-empty list")
168
+ if len(triggers) > self._max_triggers:
169
+ raise TriggerError(
170
+ f"registration may contain at most {self._max_triggers} triggers"
171
+ )
172
+
173
+ normalized: list[tuple[str, dict[str, Any]]] = []
174
+ seen: set[str] = set()
175
+ for index, trigger in enumerate(triggers):
176
+ trigger_id, specification = self._normalize_trigger(trigger, index)
177
+ if trigger_id in seen:
178
+ raise TriggerError(f"duplicate trigger id '{trigger_id}' in this batch")
179
+ seen.add(trigger_id)
180
+ normalized.append((trigger_id, specification))
181
+
182
+ for trigger_id, specification in normalized:
183
+ existing = self._triggers.get(trigger_id)
184
+ if existing is not None and existing != specification:
185
+ raise TriggerError(
186
+ f"trigger '{trigger_id}' already registered with a different spec "
187
+ "(use a new id)"
188
+ )
189
+ new_trigger_count = sum(
190
+ trigger_id not in self._triggers for trigger_id, _ in normalized
191
+ )
192
+ if len(self._triggers) + new_trigger_count > self._max_triggers:
193
+ raise TriggerError(
194
+ f"registration would exceed the {self._max_triggers} trigger limit"
195
+ )
196
+ for trigger_id, specification in normalized:
197
+ self._triggers[trigger_id] = specification
198
+ added = [trigger_id for trigger_id, _ in normalized]
199
+ self._emit("registered", detail={"added": added})
200
+ return {"ok": True, "added": added, "all": list(self._triggers)}
201
+
202
+ def decide(
203
+ self,
204
+ *,
205
+ turn: Any,
206
+ solver_message: str = "",
207
+ context_id: str = "default",
208
+ env_triggers: Any = None,
209
+ ) -> dict[str, Any]:
210
+ if isinstance(turn, bool) or not isinstance(turn, int) or turn < 1:
211
+ raise TriggerError("decide: 'turn' must be a positive integer")
212
+ if not isinstance(context_id, str) or not context_id:
213
+ raise TriggerError("decide: 'context_id' must be a non-empty string")
214
+ if len(context_id) > _MAX_CONTEXT_ID_LENGTH:
215
+ raise TriggerError(
216
+ f"decide: 'context_id' must be at most {_MAX_CONTEXT_ID_LENGTH} characters"
217
+ )
218
+ if not isinstance(solver_message, str):
219
+ raise TriggerError("decide: 'solver_message' must be a string")
220
+ if len(solver_message) > MAX_SOLVER_MESSAGE_LENGTH:
221
+ raise TriggerError(
222
+ "decide: 'solver_message' must be at most "
223
+ f"{MAX_SOLVER_MESSAGE_LENGTH} characters"
224
+ )
225
+ environment_state = env_triggers if isinstance(env_triggers, dict) else {}
226
+ context = self._contexts.get(context_id)
227
+ if context is None and len(self._contexts) >= self._max_contexts:
228
+ raise TriggerError(
229
+ f"decide: context limit of {self._max_contexts} has been reached"
230
+ )
231
+ reset = (
232
+ context is not None
233
+ and context.last_turn is not None
234
+ and turn <= context.last_turn
235
+ )
236
+ already_fired = set() if reset or context is None else context.fired
237
+ budget = _RegexBudget(self._regex_budget_seconds)
238
+
239
+ texts: list[str] = []
240
+ fired_ids: list[str] = []
241
+ done = False
242
+ for trigger_id, specification in self._triggers.items():
243
+ if specification["once"] and trigger_id in already_fired:
244
+ continue
245
+ if self._matches(
246
+ specification["when"],
247
+ turn,
248
+ solver_message,
249
+ environment_state,
250
+ budget,
251
+ ):
252
+ fired_ids.append(trigger_id)
253
+ for action in specification["actions"]:
254
+ if action["type"] == "say":
255
+ texts.append(action["text"])
256
+ elif action["type"] == "end":
257
+ done = True
258
+
259
+ # Commit only after every predicate succeeds. A timed-out decision can then
260
+ # be retried without consuming the turn or partially firing once-only rules.
261
+ context = self._context_state(context_id)
262
+ if reset:
263
+ context.fired.clear()
264
+ self._emit("reset", context_id=context_id, turn=turn)
265
+ context.last_turn = turn
266
+ for trigger_id in fired_ids:
267
+ context.fired.add(trigger_id)
268
+ self._emit(
269
+ "fired",
270
+ trigger_id=trigger_id,
271
+ turn=turn,
272
+ context_id=context_id,
273
+ )
274
+ parts = [{"kind": "text", "text": "\n".join(texts)}] if texts else []
275
+ return {"parts": parts, "done": done, "fired": fired_ids}
276
+
277
+ def state(self) -> dict[str, Any]:
278
+ return {
279
+ "triggers": [
280
+ {
281
+ "id": trigger_id,
282
+ "when_type": specification["when"]["type"],
283
+ "once": specification["once"],
284
+ }
285
+ for trigger_id, specification in self._triggers.items()
286
+ ],
287
+ "firing_log": list(self._log),
288
+ }
289
+
290
+ def _normalize_trigger(
291
+ self, trigger: Any, index: int
292
+ ) -> tuple[str, dict[str, Any]]:
293
+ if not isinstance(trigger, dict):
294
+ raise TriggerError(f"trigger[{index}] must be an object")
295
+ trigger_id = trigger.get("id")
296
+ if trigger_id is None:
297
+ trigger_id = "trg_" + uuid.uuid4().hex[:12]
298
+ elif not isinstance(trigger_id, str) or not trigger_id:
299
+ raise TriggerError(f"trigger[{index}].id must be a non-empty string")
300
+ elif len(trigger_id) > _MAX_TRIGGER_ID_LENGTH:
301
+ raise TriggerError(
302
+ f"trigger[{index}].id must be at most {_MAX_TRIGGER_ID_LENGTH} characters"
303
+ )
304
+ once = trigger.get("once", True)
305
+ if not isinstance(once, bool):
306
+ raise TriggerError(f"trigger '{trigger_id}'.once must be a boolean")
307
+ when = self._normalize_when(trigger.get("when"), trigger_id)
308
+ actions = trigger.get("actions")
309
+ if not isinstance(actions, list) or not actions:
310
+ raise TriggerError(
311
+ f"trigger '{trigger_id}'.actions must be a non-empty list"
312
+ )
313
+ specification = {
314
+ "id": trigger_id,
315
+ "once": once,
316
+ "when": when,
317
+ "actions": [
318
+ self._normalize_action(action, trigger_id) for action in actions
319
+ ],
320
+ }
321
+ _validate_specification_size(specification, trigger_id)
322
+ return trigger_id, specification
323
+
324
+ def _normalize_when(self, when: Any, trigger_id: str) -> dict[str, Any]:
325
+ if not isinstance(when, dict) or "type" not in when:
326
+ raise TriggerError(
327
+ f"trigger '{trigger_id}': 'when' must be an object with a 'type'"
328
+ )
329
+ kind = when["type"]
330
+ if kind == "step":
331
+ turn = when.get("turn", 1)
332
+ if isinstance(turn, bool) or not isinstance(turn, int) or turn < 1:
333
+ raise TriggerError(
334
+ f"trigger '{trigger_id}': step.turn must be a positive integer"
335
+ )
336
+ comparison = when.get("cmp", "eq")
337
+ if comparison not in ("eq", "gte"):
338
+ raise TriggerError(
339
+ f"trigger '{trigger_id}': step.cmp must be 'eq' or 'gte'"
340
+ )
341
+ return {"type": kind, "turn": turn, "cmp": comparison}
342
+ if kind == "env_trigger":
343
+ env_id = when.get("env_id")
344
+ source_id = when.get("trigger_id")
345
+ if not isinstance(env_id, str) or not env_id:
346
+ raise TriggerError(
347
+ f"trigger '{trigger_id}': env_trigger.env_id must be a non-empty string"
348
+ )
349
+ _validate_string_length(
350
+ env_id,
351
+ f"trigger '{trigger_id}': env_trigger.env_id",
352
+ _MAX_ENV_ID_LENGTH,
353
+ )
354
+ if not isinstance(source_id, str) or not source_id:
355
+ raise TriggerError(
356
+ f"trigger '{trigger_id}': env_trigger.trigger_id must be a non-empty string"
357
+ )
358
+ _validate_string_length(
359
+ source_id,
360
+ f"trigger '{trigger_id}': env_trigger.trigger_id",
361
+ _MAX_TRIGGER_ID_LENGTH,
362
+ )
363
+ status = when.get("status", "fired")
364
+ if not isinstance(status, str) or not status:
365
+ raise TriggerError(
366
+ f"trigger '{trigger_id}': env_trigger.status must be a non-empty string"
367
+ )
368
+ _validate_string_length(
369
+ status,
370
+ f"trigger '{trigger_id}': env_trigger.status",
371
+ _MAX_STATUS_LENGTH,
372
+ )
373
+ return {
374
+ "type": kind,
375
+ "env_id": env_id,
376
+ "trigger_id": source_id,
377
+ "status": status,
378
+ }
379
+ if kind == "conversational":
380
+ where = when.get("where")
381
+ if not isinstance(where, dict) or not where:
382
+ raise TriggerError(
383
+ f"trigger '{trigger_id}': conversational.where must be a non-empty object"
384
+ )
385
+ for field_name, predicate in where.items():
386
+ if field_name != "message":
387
+ raise TriggerError(
388
+ f"trigger '{trigger_id}': conversational.where field "
389
+ f"{field_name!r} not in ('message',)"
390
+ )
391
+ _validate_predicate(
392
+ predicate, f"trigger '{trigger_id}': where.{field_name}"
393
+ )
394
+ return {"type": kind, "where": where}
395
+ if kind in ("all", "any"):
396
+ children = when.get("of")
397
+ if not isinstance(children, list) or not children:
398
+ raise TriggerError(
399
+ f"trigger '{trigger_id}': {kind}.of must be a non-empty list"
400
+ )
401
+ return {
402
+ "type": kind,
403
+ "of": [self._normalize_when(child, trigger_id) for child in children],
404
+ }
405
+ raise TriggerError(f"trigger '{trigger_id}': unknown when.type {kind!r}")
406
+
407
+ def _normalize_action(self, action: Any, trigger_id: str) -> dict[str, Any]:
408
+ if not isinstance(action, dict) or "type" not in action:
409
+ raise TriggerError(
410
+ f"trigger '{trigger_id}': each action must be an object with a 'type'"
411
+ )
412
+ kind = action["type"]
413
+ if kind == "say":
414
+ text = action.get("text")
415
+ if not isinstance(text, str) or not text:
416
+ raise TriggerError(
417
+ f"trigger '{trigger_id}': say.text must be a non-empty string"
418
+ )
419
+ _validate_string_length(
420
+ text,
421
+ f"trigger '{trigger_id}': say.text",
422
+ _MAX_ACTION_TEXT_LENGTH,
423
+ )
424
+ return {"type": kind, "text": text}
425
+ if kind == "end":
426
+ return {"type": kind}
427
+ raise TriggerError(f"trigger '{trigger_id}': unknown action.type {kind!r}")
428
+
429
+ def _matches(
430
+ self,
431
+ when: dict[str, Any],
432
+ turn: int,
433
+ solver_message: str,
434
+ env_triggers: dict[str, Any],
435
+ budget: _RegexBudget,
436
+ ) -> bool:
437
+ kind = when["type"]
438
+ if kind == "step":
439
+ return turn == when["turn"] if when["cmp"] == "eq" else turn >= when["turn"]
440
+ if kind == "env_trigger":
441
+ return (
442
+ env_triggers.get(when["env_id"], {}).get(when["trigger_id"])
443
+ == when["status"]
444
+ )
445
+ if kind == "conversational":
446
+ return all(
447
+ _matches_predicate(predicate, solver_message, budget)
448
+ for predicate in when["where"].values()
449
+ )
450
+ if kind == "all":
451
+ return all(
452
+ self._matches(child, turn, solver_message, env_triggers, budget)
453
+ for child in when["of"]
454
+ )
455
+ if kind == "any":
456
+ return any(
457
+ self._matches(child, turn, solver_message, env_triggers, budget)
458
+ for child in when["of"]
459
+ )
460
+ return False
461
+
462
+ def _context_state(self, context_id: str) -> _ContextState:
463
+ """Return context state without evicting one-shot firing history.
464
+
465
+ A ``once`` trigger is once per context. Silently evicting a context
466
+ would discard that history and replay actions if the caller returned to
467
+ the same context, so a full bounded cache rejects new contexts instead.
468
+ """
469
+ context = self._contexts.get(context_id)
470
+ if context is not None:
471
+ self._contexts.move_to_end(context_id)
472
+ return context
473
+ if len(self._contexts) >= self._max_contexts:
474
+ raise TriggerError(
475
+ f"decide: context limit of {self._max_contexts} has been reached"
476
+ )
477
+ context = _ContextState()
478
+ self._contexts[context_id] = context
479
+ return context
480
+
481
+ def _emit(self, kind: str, **fields: Any) -> None:
482
+ self._sequence += 1
483
+ entry = {
484
+ "seq": self._sequence,
485
+ "ts": datetime.now(timezone.utc).isoformat(),
486
+ "kind": kind,
487
+ }
488
+ entry.update(fields)
489
+ self._log.append(entry)