diff --git a/docs/docs/reference/resources/functions.md b/docs/docs/reference/resources/functions.md index f98261aa..f95c0ebf 100644 --- a/docs/docs/reference/resources/functions.md +++ b/docs/docs/reference/resources/functions.md @@ -115,12 +115,17 @@ Adding this decorator to a function enables latency control for it. It accepts: | `delay_before_responses_start` | `int` (0–10) | How long to wait before playing the first delay message | | `silence_after_each_response` | `int` (0–10) | Minimum gap to leave between delay messages | | `delay_responses` | `list[tuple[str, int]]` | `(message, duration)` pairs to play while the function runs | +| `randomize` | `bool` | When `True`, shuffle delay response order on each function invocation (timing slots are preserved). Default `False` | ~~~python @func_latency_control( delay_before_responses_start=2, silence_after_each_response=3, - delay_responses=[("Let me check that for you.", 3)], + delay_responses=[ + ("Let me check that for you.", 3), + ("Still working on it.", 2), + ], + randomize=True, ) ~~~ diff --git a/src/poly/docs/functions.md b/src/poly/docs/functions.md index 3acb17e9..15c8faf7 100644 --- a/src/poly/docs/functions.md +++ b/src/poly/docs/functions.md @@ -40,6 +40,8 @@ flows/{flow_name}/ - **`@func_description('...')`** (required for global and transition functions): Description shown to the LLM to decide when to call the function. - **`@func_parameter('param_name', '...')`** (required for each parameter except `conv` and `flow`): Description of the parameter shown to the LLM. All parameters must also have a typed Python annotation (e.g. `booking_ref: str`) - **`@func_latency_control(...)`** (optional): Configure delay messages while the function runs. + Supports `delay_before_responses_start`, `silence_after_each_response`, `delay_responses`, and + `randomize` (shuffle delay response order on each invocation; default `False`). Function steps do not support `@func_parameter` or `@func_description`. diff --git a/src/poly/resources/function.py b/src/poly/resources/function.py index e5ddfa5c..b0f6c8a1 100644 --- a/src/poly/resources/function.py +++ b/src/poly/resources/function.py @@ -105,6 +105,7 @@ class FunctionLatencyControl: initial_delay: int = 0 interval: int = 0 delay_responses: list[FunctionDelayResponse] = field(default_factory=list) + randomize: bool = False def __post_init__(self): self.delay_responses = [ @@ -147,6 +148,7 @@ def parse_latency_control(latency_control_data: dict) -> FunctionLatencyControl: ), interval=latency_control_data.get("interval", 0), delay_responses=delay_responses, + randomize=latency_control_data.get("randomize", False), ) @@ -193,6 +195,7 @@ def to_proto(self, enabled_override: Optional[bool] = None) -> Function_UpdateLa delay_responses=delay_responses, initial_delay=self.latency_control.initial_delay if enabled else 0, interval=self.latency_control.interval if enabled else 0, + randomize=self.latency_control.randomize if enabled else False, ) def build_update_proto(self) -> Message: @@ -265,6 +268,7 @@ def _parse_latency_control(value) -> FunctionLatencyControl: initial_delay=value.get("initial_delay", value.get("initialDelay", 0)), interval=value.get("interval", 0), delay_responses=value.get("delay_responses", []), + randomize=value.get("randomize", False), ) return FunctionLatencyControl() @@ -568,6 +572,8 @@ def _render_latency_control_decorator(lc: FunctionLatencyControl, indent: str) - if lc.delay_responses: dr_items = ", ".join(f"({dr.message!r}, {dr.duration!r})" for dr in lc.delay_responses) parts.append(f"delay_responses=[{dr_items}]") + if lc.randomize: + parts.append("randomize=True") return f"{indent}@func_latency_control({', '.join(parts)})\n" def validate(self, **kwargs) -> None: @@ -775,12 +781,15 @@ def _parse_latency_control_decorator( interval = 0 delay_responses: list[FunctionDelayResponse] = [] used_delay_response_ids: set[str] = set() + randomize = False for kw in decorator.keywords: if kw.arg == "delay_before_responses_start" and isinstance(kw.value, ast.Constant): initial_delay = kw.value.value elif kw.arg == "silence_after_each_response" and isinstance(kw.value, ast.Constant): interval = kw.value.value + elif kw.arg == "randomize" and isinstance(kw.value, ast.Constant): + randomize = bool(kw.value.value) elif kw.arg == "delay_responses" and isinstance(kw.value, ast.List): for elt in kw.value.elts: if isinstance(elt, ast.Tuple) and len(elt.elts) == 2: @@ -812,6 +821,7 @@ def _parse_latency_control_decorator( initial_delay=initial_delay, interval=interval, delay_responses=delay_responses, + randomize=randomize, ) @staticmethod @@ -964,6 +974,7 @@ def _build_create_latency_control_proto(self) -> FunctionCreateLatencyControl: delay_responses=delay_responses, initial_delay=self.latency_control.initial_delay, interval=self.latency_control.interval, + randomize=self.latency_control.randomize, ) def build_create_proto(self) -> Message: diff --git a/src/poly/tests/resources_test.py b/src/poly/tests/resources_test.py index e6401b7e..f6554c03 100644 --- a/src/poly/tests/resources_test.py +++ b/src/poly/tests/resources_test.py @@ -61,6 +61,7 @@ FunctionLatencyControl, FunctionParameters, FunctionType, + LatencyControl, ) from poly.resources.handoff import Handoff from poly.resources.keyphrase_boosting import KeyphraseBoosting @@ -518,6 +519,30 @@ def test_raw_includes_latency_control_decorator(self): self.assertIn("silence_after_each_response=3000", raw) self.assertIn("('Please hold...', 5000)", raw) self.assertIn("('Still looking...', 8000)", raw) + self.assertNotIn("randomize=", raw) + + def test_raw_includes_randomize_when_enabled(self): + """randomize=True is rendered on @func_latency_control when set.""" + func = Function( + resource_id="123", + name="test_code", + description="A test function", + code=TEST_CODE, + parameters=[], + latency_control=FunctionLatencyControl( + enabled=True, + initial_delay=0, + interval=2, + delay_responses=[ + FunctionDelayResponse(message="One...", duration=3000), + FunctionDelayResponse(message="Two...", duration=2000), + ], + randomize=True, + ), + function_type=FunctionType.GLOBAL, + ) + raw = func.raw + self.assertIn("randomize=True", raw) def test_raw_omits_latency_control_when_disabled(self): """When latency_control.enabled is False, no decorator is rendered.""" @@ -545,12 +570,23 @@ def my_func(conv: Conversation): self.assertTrue(lc.enabled) self.assertEqual(lc.initial_delay, 5000) self.assertEqual(lc.interval, 3000) + self.assertFalse(lc.randomize) self.assertEqual(len(lc.delay_responses), 1) self.assertEqual(lc.delay_responses[0].message, "Hold on...") self.assertEqual(lc.delay_responses[0].duration, 5000) # Decorator should be stripped from code self.assertNotIn("func_latency_control", code) + def test_extract_latency_control_randomize(self): + """_extract_decorators parses randomize from @func_latency_control.""" + code_with_decorator = """@func_latency_control(delay_before_responses_start=0, silence_after_each_response=2, delay_responses=[('Hold on...', 3000)], randomize=True) +def my_func(conv: Conversation): + pass +""" + _, _, _, lc = Function._extract_decorators(code_with_decorator, "my_func", []) + self.assertTrue(lc.enabled) + self.assertTrue(lc.randomize) + def test_extract_preserves_known_delay_response_ids(self): """Existing delay-response IDs are preserved by message match.""" code_with_decorator = """@func_latency_control(delay_before_responses_start=1000, silence_after_each_response=2000, delay_responses=[('Hold on...', 5000)]) @@ -578,6 +614,7 @@ def test_latency_control_roundtrip(self): FunctionDelayResponse(id="DR-1", message="One moment...", duration=4000), FunctionDelayResponse(id="DR-2", message="Almost there...", duration=6000), ], + randomize=True, ) func = Function( resource_id="123", @@ -591,6 +628,7 @@ def test_latency_control_roundtrip(self): function_type=FunctionType.GLOBAL, ) pretty = func.to_pretty(resource_mappings=[]) + self.assertIn("randomize=True", pretty) reverted = Function.from_pretty(pretty, resource_mappings=[]) code, params, desc, extracted_lc = Function._extract_decorators( reverted, "test_code", [], lc @@ -598,6 +636,7 @@ def test_latency_control_roundtrip(self): self.assertTrue(extracted_lc.enabled) self.assertEqual(extracted_lc.initial_delay, 4000) self.assertEqual(extracted_lc.interval, 2000) + self.assertTrue(extracted_lc.randomize) self.assertEqual(len(extracted_lc.delay_responses), 2) self.assertEqual(extracted_lc.delay_responses[0].message, "One moment...") self.assertEqual(extracted_lc.delay_responses[1].message, "Almost there...") @@ -605,6 +644,24 @@ def test_latency_control_roundtrip(self): self.assertEqual(extracted_lc.delay_responses[0].id, "DR-1") self.assertEqual(extracted_lc.delay_responses[1].id, "DR-2") + def test_latency_control_to_proto_includes_randomize(self): + """LatencyControl.to_proto sets randomize on the update command.""" + sub = LatencyControl( + function_id="fn-1", + latency_control=FunctionLatencyControl( + enabled=True, + initial_delay=0, + interval=2, + delay_responses=[ + FunctionDelayResponse(id="DR-1", message="One...", duration=3000), + ], + randomize=True, + ), + ) + proto = sub.to_proto() + self.assertTrue(proto.randomize) + self.assertTrue(proto.HasField("randomize")) + def test_read_local_resource_with_latency_control(self): """read_local_resource correctly extracts latency control from file.""" test_file_content = """from _gen import * # diff --git a/src/poly/tests/test_projects/test_project/_gen/decorators.py b/src/poly/tests/test_projects/test_project/_gen/decorators.py index 7379d028..97139ea2 100644 --- a/src/poly/tests/test_projects/test_project/_gen/decorators.py +++ b/src/poly/tests/test_projects/test_project/_gen/decorators.py @@ -40,6 +40,7 @@ def func_latency_control( delay_before_responses_start: int = 0, silence_after_each_response: int = 0, delay_responses: Optional[list[tuple[str, int]]] = None, + randomize: bool = False, ) -> Callable: """Configure latency control for a function. @@ -50,6 +51,10 @@ def func_latency_control( delay response. Must be between 0 and 10. delay_responses: A list of (message, duration_ms) tuples that are played while the function is executing. + randomize: When True, shuffle delay_responses order on each function + invocation. Timing slots are preserved (first uses + delay_before_responses_start; later slots use + silence_after_each_response). """ def decorator(func: Callable) -> Callable: diff --git a/src/poly/utils/decorators.py b/src/poly/utils/decorators.py index 3c56ff66..cd4f9edd 100644 --- a/src/poly/utils/decorators.py +++ b/src/poly/utils/decorators.py @@ -16,6 +16,7 @@ def func_latency_control( delay_before_responses_start: int = 0, silence_after_each_response: int = 0, delay_responses: Optional[list[tuple[str, int]]] = None, + randomize: bool = False, ) -> Callable: """Configure latency control for a function. @@ -26,6 +27,10 @@ def func_latency_control( delay response. Must be between 0 and 10. delay_responses: A list of (message, duration_ms) tuples that are played while the function is executing. + randomize: When True, shuffle delay_responses order on each function + invocation. Timing slots are preserved (first uses + delay_before_responses_start; later slots use + silence_after_each_response). """ def decorator(func: Callable) -> Callable: