Skip to content
Merged
41 changes: 41 additions & 0 deletions autogpt_platform/backend/backend/blocks/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import jsonref
import jsonschema
from jsonschema.validators import validator_for as _jsonschema_validator_for
from pydantic import BaseModel, Field

from backend.data.block import BlockInput, BlockOutput, BlockOutputEntry
Expand Down Expand Up @@ -256,6 +257,46 @@ def validate_data(
def get_mismatch_error(cls, data: BlockInput) -> str | None:
return cls.validate_data(data)

# JSON-schema keywords whose violations are NOT already prevented at the
# widget level (number bounds and length bounds are bypassable by typing
# or pasting). ``enum``/``const`` come from dropdowns and ``pattern``/
# ``multipleOf``/``type`` are enforced by custom render rules, so leaving
# them out avoids surfacing errors the user can't actually trigger.
_INLINE_FIELD_ERROR_KEYWORDS: ClassVar[frozenset[str]] = frozenset(
{
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"minItems",
"maxItems",
}
)

@classmethod
def get_field_errors(cls, data: BlockInput) -> dict[str, str]:
"""
Validate ``data`` against this schema and return per-field errors for
violations that are not already blocked by the form widget — i.e.
bound checks the user can bypass by typing/pasting. Lets these surface
inline on the offending block field via the standard ``node_errors``
path, rather than as a single string raised at execute time.
"""
schema = cls.jsonschema()
cleaned = {k: v for k, v in data.items() if v is not None}
validator_cls = _jsonschema_validator_for(schema)
errors: dict[str, str] = {}
for err in validator_cls(schema).iter_errors(cleaned):
if err.validator not in cls._INLINE_FIELD_ERROR_KEYWORDS:
continue
if not err.absolute_path:
continue
field = str(err.absolute_path[0])
errors.setdefault(field, err.message)
return errors

@classmethod
def get_field_schema(cls, field_name: str) -> dict[str, Any]:
model_schema = cls.jsonschema().get("properties", {})
Expand Down
49 changes: 43 additions & 6 deletions autogpt_platform/backend/backend/blocks/time_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,15 +443,24 @@ class Input(BlockSchemaInput):
advanced=False, description="Duration in days", default=0
)
repeat: int = SchemaField(
description="Number of times to repeat the timer",
description="Number of times to repeat the timer (1–1000)",
default=1,
ge=1,
le=1000,
Comment thread
kcze marked this conversation as resolved.
)

class Output(BlockSchemaOutput):
output_message: Any = SchemaField(
description="Message after the timer finishes"
)

MAX_TOTAL_SECONDS = 7 * 86400 # 7 days
MIN_REPEAT = 1
MAX_REPEAT = 1000
# Override the default 30-minute block timeout so the configured cap
# is actually reachable; add a small buffer for scheduler overhead.
execution_timeout_seconds: int | None = MAX_TOTAL_SECONDS + 60

def __init__(self):
super().__init__(
id="d67a9c52-5e4e-11e2-bcfd-0800200c9a71",
Expand All @@ -469,15 +478,43 @@ def __init__(self):
],
)

@staticmethod
def _coerce_duration_field(field_name: str, value: Union[int, str]) -> int:
try:
return int(value)
except (TypeError, ValueError):
raise ValueError(f"{field_name} must be a valid integer, got {value!r}")

async def run(self, input_data: Input, **kwargs) -> BlockOutput:
seconds = int(input_data.seconds)
minutes = int(input_data.minutes)
hours = int(input_data.hours)
days = int(input_data.days)
seconds = self._coerce_duration_field("seconds", input_data.seconds)
minutes = self._coerce_duration_field("minutes", input_data.minutes)
hours = self._coerce_duration_field("hours", input_data.hours)
days = self._coerce_duration_field("days", input_data.days)
repeat = input_data.repeat

# Defense-in-depth: also enforce here in case Pydantic constraints
# are bypassed by a caller that constructs Input.model_construct().
if not self.MIN_REPEAT <= repeat <= self.MAX_REPEAT:
raise ValueError(
f"Repeat must be between {self.MIN_REPEAT} and {self.MAX_REPEAT}, "
Comment thread
kcze marked this conversation as resolved.
f"got {repeat}"
)

total_seconds = seconds + minutes * 60 + hours * 3600 + days * 86400

for _ in range(input_data.repeat):
if total_seconds < 0:
raise ValueError(
f"Countdown duration must be non-negative, got {total_seconds}s"
)
cumulative_seconds = total_seconds * repeat
if cumulative_seconds > self.MAX_TOTAL_SECONDS:
raise ValueError(
f"Cumulative countdown duration {cumulative_seconds}s "
f"(per-iteration {total_seconds}s × repeat {repeat}) "
f"exceeds max ({self.MAX_TOTAL_SECONDS}s = 7 days)"
)

for _ in range(repeat):
if total_seconds > 0:
await asyncio.sleep(total_seconds)
yield "output_message", input_data.input_message
167 changes: 167 additions & 0 deletions autogpt_platform/backend/backend/blocks/time_blocks_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
from unittest.mock import AsyncMock

import pytest
from pydantic import ValidationError

from backend.blocks.basic import StoreValueBlock
from backend.blocks.time_blocks import CountdownTimerBlock
from backend.data.graph import GraphModel, Link, Node


async def _run(block: CountdownTimerBlock, **input_kwargs):
outputs = []
async for name, value in block.run(block.input_schema(**input_kwargs)):
outputs.append((name, value))
return outputs


@pytest.mark.asyncio
async def test_countdown_timer_rejects_excessive_duration():
block = CountdownTimerBlock()
with pytest.raises(ValueError, match="exceeds max"):
await _run(block, days=365, repeat=1)


@pytest.mark.asyncio
async def test_countdown_timer_rejects_cumulative_duration_over_cap():
block = CountdownTimerBlock()
with pytest.raises(ValueError, match="exceeds max"):
await _run(block, days=1, repeat=10)


@pytest.mark.asyncio
async def test_countdown_timer_rejects_negative_duration():
block = CountdownTimerBlock()
with pytest.raises(ValueError, match="non-negative"):
await _run(block, seconds="-1")


def test_countdown_timer_rejects_repeat_zero_at_schema():
block = CountdownTimerBlock()
with pytest.raises(ValidationError):
block.input_schema(seconds=1, repeat=0)


def test_countdown_timer_rejects_repeat_over_max_at_schema():
block = CountdownTimerBlock()
with pytest.raises(ValidationError):
block.input_schema(seconds=1, repeat=1001)


@pytest.mark.asyncio
async def test_countdown_timer_run_rejects_repeat_zero_defense_in_depth():
block = CountdownTimerBlock()
bypassed = block.input_schema.model_construct(seconds=1, repeat=0)
with pytest.raises(ValueError, match="Repeat must be between"):
async for _ in block.run(bypassed):
pass


@pytest.mark.asyncio
async def test_countdown_timer_run_rejects_repeat_over_max_defense_in_depth():
block = CountdownTimerBlock()
bypassed = block.input_schema.model_construct(seconds=1, repeat=1001)
with pytest.raises(ValueError, match="Repeat must be between"):
async for _ in block.run(bypassed):
pass


@pytest.mark.asyncio
async def test_countdown_timer_allows_duration_at_cap(mocker):
sleep_mock = mocker.patch(
"backend.blocks.time_blocks.asyncio.sleep", new_callable=AsyncMock
)
block = CountdownTimerBlock()
outputs = await _run(block, days=7, repeat=1)
assert outputs == [("output_message", "timer finished")]
sleep_mock.assert_awaited_once_with(7 * 86400)


def test_countdown_timer_execution_timeout_covers_max_duration():
block = CountdownTimerBlock()
assert block.execution_timeout_seconds is not None
assert block.execution_timeout_seconds >= block.MAX_TOTAL_SECONDS


def test_countdown_timer_get_field_errors_reports_per_field_bound_violations():
block = CountdownTimerBlock()
errors = block.input_schema.get_field_errors({"repeat": 1200})
assert "repeat" in errors
assert "1200" in errors["repeat"]
assert "1000" in errors["repeat"]


def test_countdown_timer_get_field_errors_clean_when_within_bounds():
block = CountdownTimerBlock()
assert block.input_schema.get_field_errors({"repeat": 5}) == {}


@pytest.mark.asyncio
async def test_countdown_timer_rejects_non_numeric_string_duration():
block = CountdownTimerBlock()
with pytest.raises(ValueError, match="seconds must be a valid integer"):
await _run(block, seconds="abc")


@pytest.mark.asyncio
async def test_countdown_timer_emits_one_message_per_repeat(mocker):
sleep_mock = mocker.patch(
"backend.blocks.time_blocks.asyncio.sleep", new_callable=AsyncMock
)
block = CountdownTimerBlock()
outputs = await _run(block, seconds=1, repeat=3)
assert outputs == [("output_message", "timer finished")] * 3
assert sleep_mock.await_count == 3


def _countdown_node(node_id: str, **input_default) -> Node:
return Node(
id=node_id,
block_id=CountdownTimerBlock().id,
input_default=input_default,
)


def _graph(nodes: list[Node], links: list[Link] | None = None) -> GraphModel:
# Bypass GraphModel field validators by constructing the structural fields
# directly — these tests only exercise the per-field jsonschema bound check
# in ``_validate_graph_get_errors`` and don't need DB-side metadata.
return GraphModel.model_construct(
id="g",
version=1,
name="t",
description="t",
nodes=nodes,
links=links or [],
sub_graphs=[],
)


def test_validate_graph_surfaces_bound_violation_inline_on_field():
node = _countdown_node("n1", repeat=1200)
graph = _graph([node])

node_errors = graph.validate_graph_get_errors(for_run=True)

assert "n1" in node_errors
assert "repeat" in node_errors["n1"]
assert "1000" in node_errors["n1"]["repeat"]


def test_validate_graph_skips_bound_check_when_field_is_linked():
# ``repeat`` is linked from an upstream block — the runtime value isn't
# known at validation time, so the saved ``input_default`` placeholder
# (here, an out-of-range value) should NOT raise a spurious field error.
source = Node(id="src", block_id=StoreValueBlock().id, input_default={"input": 5})
countdown = _countdown_node("n1", repeat=1200)
link = Link(
source_id="src",
sink_id="n1",
source_name="output",
sink_name="repeat",
)
graph = _graph([source, countdown], [link])

node_errors = graph.validate_graph_get_errors(for_run=True)

assert "repeat" not in node_errors.get("n1", {})
24 changes: 24 additions & 0 deletions autogpt_platform/backend/backend/data/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,30 @@ def _validate_graph_get_errors(
):
node_errors[node.id][field_name] = "This field is required"

# Validate field-level JSON-schema bound constraints
# (minimum/maximum/exclusiveMinimum/exclusiveMaximum and
# minLength/maxLength/minItems/maxItems — i.e. the keywords
# ``get_field_errors`` surfaces, see ``_INLINE_FIELD_ERROR_KEYWORDS``)
# so violations surface inline on the offending field via the
# same ``node_errors`` path used by structural checks. Skip
# fields whose value comes from an upstream link — the runtime
# value is unknown here, and the saved ``input_default`` for a
# linked field may be a placeholder.
linked_field_names = {
sanitize_pin_name(link.sink_name)
for link in input_links.get(node.id, [])
}
field_data = {
k: v
for k, v in {**node.input_default, **node_input_mask}.items()
if sanitize_pin_name(k) not in linked_field_names
}
for field_name, message in InputSchema.get_field_errors(
field_data
).items():
if field_name not in node_errors[node.id]:
node_errors[node.id][field_name] = message

# Get input schema properties and check dependencies
input_fields = InputSchema.model_fields

Expand Down
6 changes: 3 additions & 3 deletions docs/integrations/block-integrations/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ This block triggers after a specified duration.

### How it works
<!-- MANUAL: how_it_works -->
The Countdown Timer block pauses workflow execution for a specified duration before continuing. You can set the delay using any combination of seconds, minutes, hours, and days. When the timer completes, it outputs your specified message (or "timer finished" by default).
The Countdown Timer block pauses workflow execution for a specified duration before continuing. You can set the delay using any combination of seconds, minutes, hours, and days. The cumulative duration (per-iteration delay × repeat count) is capped at 7 days. When the timer completes, it outputs your specified message (or "timer finished" by default).

The block supports a repeat parameter, allowing the timer to fire multiple times in sequence—useful for creating periodic triggers within your workflow. The timer uses async sleep, so it doesn't block other concurrent operations in the system.
The block supports a repeat parameter (1–1000), allowing the timer to fire multiple times in sequence—useful for creating periodic triggers within your workflow. The timer uses async sleep, so it doesn't block other concurrent operations in the system.
<!-- END MANUAL -->

### Inputs
Expand All @@ -107,7 +107,7 @@ The block supports a repeat parameter, allowing the timer to fire multiple times
| minutes | Duration in minutes | int \| str | No |
| hours | Duration in hours | int \| str | No |
| days | Duration in days | int \| str | No |
| repeat | Number of times to repeat the timer | int | No |
| repeat | Number of times to repeat the timer (1–1000) | int | No |

### Outputs

Expand Down
3 changes: 2 additions & 1 deletion docs/integrations/block-integrations/time_blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ A block that acts as a countdown timer, triggering after a specified duration.
This block waits for a specified amount of time and then outputs a message.

### How it works
The block takes input for the duration in days, hours, minutes, and seconds. It calculates the total wait time in seconds, pauses execution for that duration, and then outputs the specified message.
The block takes input for the duration in days, hours, minutes, and seconds. It calculates the total wait time in seconds, pauses execution for that duration, and then outputs the specified message. The cumulative duration (per-iteration delay × `repeat`) is capped at 7 days, and `repeat` is bounded to 1–1000.

### Inputs
| Input | Description | Default |
Expand All @@ -96,6 +96,7 @@ The block takes input for the duration in days, hours, minutes, and seconds. It
| minutes | The number of minutes to wait. | 0 |
| hours | The number of hours to wait. | 0 |
| days | The number of days to wait. | 0 |
| repeat | The number of times to repeat the timer (1–1000). | 1 |

### Outputs
| Output | Description |
Expand Down
Loading