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
7 changes: 6 additions & 1 deletion docs/docs/reference/resources/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
~~~

Expand Down
2 changes: 2 additions & 0 deletions src/poly/docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
11 changes: 11 additions & 0 deletions src/poly/resources/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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),
)


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -812,6 +821,7 @@ def _parse_latency_control_decorator(
initial_delay=initial_delay,
interval=interval,
delay_responses=delay_responses,
randomize=randomize,
)

@staticmethod
Expand Down Expand Up @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions src/poly/tests/resources_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
FunctionLatencyControl,
FunctionParameters,
FunctionType,
LatencyControl,
)
from poly.resources.handoff import Handoff
from poly.resources.keyphrase_boosting import KeyphraseBoosting
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)])
Expand Down Expand Up @@ -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",
Expand All @@ -591,20 +628,40 @@ 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
)
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...")
# IDs are preserved
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 * # <AUTO GENERATED>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/poly/utils/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
Loading