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.
- agentenv_framework_protocol-0.1.269.dist-info/METADATA +599 -0
- agentenv_framework_protocol-0.1.269.dist-info/RECORD +20 -0
- agentenv_framework_protocol-0.1.269.dist-info/WHEEL +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/LICENSE +202 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/NOTICE +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/THIRD_PARTY_NOTICES.md +1701 -0
- agentenv_protocol/__init__.py +121 -0
- agentenv_protocol/a2a_agent/__init__.py +204 -0
- agentenv_protocol/a2a_agent/_triggers.py +489 -0
- agentenv_protocol/a2a_agent/extensions.py +1151 -0
- agentenv_protocol/a2a_agent/framework.py +1283 -0
- agentenv_protocol/a2a_agent/registry.py +449 -0
- agentenv_protocol/a2a_agent/tasks/__init__.py +39 -0
- agentenv_protocol/a2a_agent/tasks/v1.py +408 -0
- agentenv_protocol/agent_env_environment.py +653 -0
- agentenv_protocol/client.py +185 -0
- agentenv_protocol/manifest.py +203 -0
- agentenv_protocol/preflight.py +81 -0
- agentenv_protocol/transfers.py +554 -0
- agentenv_protocol/types.py +165 -0
|
@@ -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)
|