Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/docs/reference/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ Global and transition functions use decorators to describe themselves to the mod
| `@func_parameter("name", "...")` | Describes a parameter |
| `@func_latency_control(...)` | Configures delay messaging while the function runs |

`@func_latency_control` accepts `delay_before_responses_start`, `silence_after_each_response`,
`delay_responses`, and optional `randomize=True` to shuffle delay response order on each
function invocation (timing slots are preserved).

Function steps do not support `@func_description` or `@func_parameter`.

!!! warning "All parameters must have a type annotation and no default value"
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
72 changes: 36 additions & 36 deletions src/poly/handlers/protobuf/functions_pb2.py

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions src/poly/handlers/protobuf/functions_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class FunctionDelayResponse(_message.Message):
def __init__(self, id: _Optional[str] = ..., message: _Optional[str] = ..., duration: _Optional[int] = ..., created_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., created_by: _Optional[str] = ..., updated_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., updated_by: _Optional[str] = ..., references: _Optional[_Union[FunctionDelayResponseReferences, _Mapping]] = ...) -> None: ...

class FunctionLatencyControl(_message.Message):
__slots__ = ("enabled", "delay_responses", "initial_delay", "interval", "created_at", "created_by", "updated_at", "updated_by")
__slots__ = ("enabled", "delay_responses", "initial_delay", "interval", "created_at", "created_by", "updated_at", "updated_by", "randomize")
ENABLED_FIELD_NUMBER: _ClassVar[int]
DELAY_RESPONSES_FIELD_NUMBER: _ClassVar[int]
INITIAL_DELAY_FIELD_NUMBER: _ClassVar[int]
Expand All @@ -88,6 +88,7 @@ class FunctionLatencyControl(_message.Message):
CREATED_BY_FIELD_NUMBER: _ClassVar[int]
UPDATED_AT_FIELD_NUMBER: _ClassVar[int]
UPDATED_BY_FIELD_NUMBER: _ClassVar[int]
RANDOMIZE_FIELD_NUMBER: _ClassVar[int]
enabled: bool
delay_responses: _containers.RepeatedCompositeFieldContainer[FunctionDelayResponse]
initial_delay: int
Expand All @@ -96,7 +97,8 @@ class FunctionLatencyControl(_message.Message):
created_by: str
updated_at: _timestamp_pb2.Timestamp
updated_by: str
def __init__(self, enabled: bool = ..., delay_responses: _Optional[_Iterable[_Union[FunctionDelayResponse, _Mapping]]] = ..., initial_delay: _Optional[int] = ..., interval: _Optional[int] = ..., created_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., created_by: _Optional[str] = ..., updated_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., updated_by: _Optional[str] = ...) -> None: ...
randomize: bool
def __init__(self, enabled: bool = ..., delay_responses: _Optional[_Iterable[_Union[FunctionDelayResponse, _Mapping]]] = ..., initial_delay: _Optional[int] = ..., interval: _Optional[int] = ..., created_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., created_by: _Optional[str] = ..., updated_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., updated_by: _Optional[str] = ..., randomize: bool = ...) -> None: ...

class FunctionReferences(_message.Message):
__slots__ = ("flow_steps", "topics", "stop_keywords", "behaviour", "variables")
Expand Down Expand Up @@ -196,16 +198,18 @@ class FunctionParameterUpdate(_message.Message):
def __init__(self, id: _Optional[str] = ..., name: _Optional[str] = ..., description: _Optional[str] = ..., type: _Optional[str] = ...) -> None: ...

class FunctionCreateLatencyControl(_message.Message):
__slots__ = ("enabled", "delay_responses", "initial_delay", "interval")
__slots__ = ("enabled", "delay_responses", "initial_delay", "interval", "randomize")
ENABLED_FIELD_NUMBER: _ClassVar[int]
DELAY_RESPONSES_FIELD_NUMBER: _ClassVar[int]
INITIAL_DELAY_FIELD_NUMBER: _ClassVar[int]
INTERVAL_FIELD_NUMBER: _ClassVar[int]
RANDOMIZE_FIELD_NUMBER: _ClassVar[int]
enabled: bool
delay_responses: _containers.RepeatedCompositeFieldContainer[FunctionDelayResponse]
initial_delay: int
interval: int
def __init__(self, enabled: bool = ..., delay_responses: _Optional[_Iterable[_Union[FunctionDelayResponse, _Mapping]]] = ..., initial_delay: _Optional[int] = ..., interval: _Optional[int] = ...) -> None: ...
randomize: bool
def __init__(self, enabled: bool = ..., delay_responses: _Optional[_Iterable[_Union[FunctionDelayResponse, _Mapping]]] = ..., initial_delay: _Optional[int] = ..., interval: _Optional[int] = ..., randomize: bool = ...) -> None: ...

class Function_CreateFunction(_message.Message):
__slots__ = ("id", "name", "description", "parameters", "code", "errors", "latency_control", "references", "archived")
Expand Down Expand Up @@ -286,15 +290,17 @@ class DelayResponsesUpdate(_message.Message):
def __init__(self, delay_responses: _Optional[_Iterable[_Union[DelayResponseUpdate, _Mapping]]] = ...) -> None: ...

class Function_UpdateLatencyControl(_message.Message):
__slots__ = ("function_id", "enabled", "delay_responses", "initial_delay", "interval")
__slots__ = ("function_id", "enabled", "delay_responses", "initial_delay", "interval", "randomize")
FUNCTION_ID_FIELD_NUMBER: _ClassVar[int]
ENABLED_FIELD_NUMBER: _ClassVar[int]
DELAY_RESPONSES_FIELD_NUMBER: _ClassVar[int]
INITIAL_DELAY_FIELD_NUMBER: _ClassVar[int]
INTERVAL_FIELD_NUMBER: _ClassVar[int]
RANDOMIZE_FIELD_NUMBER: _ClassVar[int]
function_id: str
enabled: bool
delay_responses: DelayResponsesUpdate
initial_delay: int
interval: int
def __init__(self, function_id: _Optional[str] = ..., enabled: bool = ..., delay_responses: _Optional[_Union[DelayResponsesUpdate, _Mapping]] = ..., initial_delay: _Optional[int] = ..., interval: _Optional[int] = ...) -> None: ...
randomize: bool
def __init__(self, function_id: _Optional[str] = ..., enabled: bool = ..., delay_responses: _Optional[_Union[DelayResponsesUpdate, _Mapping]] = ..., initial_delay: _Optional[int] = ..., interval: _Optional[int] = ..., randomize: bool = ...) -> None: ...
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 @@ -52,6 +52,7 @@
FunctionLatencyControl,
FunctionParameters,
FunctionType,
LatencyControl,
)
from poly.resources.handoff import Handoff
from poly.resources.keyphrase_boosting import KeyphraseBoosting
Expand Down Expand Up @@ -503,6 +504,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 @@ -530,12 +555,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 @@ -563,6 +599,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 @@ -576,20 +613,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