Files
2026-07-13 13:12:00 +08:00

298 lines
12 KiB
Python

"""
LLM-callable timer builtins.
Two tools:
- :class:`SysTimerSetTool` (``sys_timer_set``) — schedules a timer
that fires a notification after a delay.
- :class:`SysTimerCancelTool` (``sys_timer_cancel``) — cancels a
previously scheduled timer by ``timer_id``.
Both tools are gated on the agent spec's top-level ``timers:`` flag
(see :attr:`AgentSpec.timers`, defaulting to ``False``).
These classes own the LLM-facing schema and argument validation.
The firing itself runs in the runner: ``execute_tool`` intercepts
``sys_timer_set`` / ``sys_timer_cancel`` and dispatches to
:func:`omnigent.runner.tool_dispatch._execute_timer_set` /
``_execute_timer_cancel``, which run the sleep-and-wake loop and own
the per-session timer registry. The shared :func:`validate_timer_set_args`
helper keeps both surfaces rejecting the same inputs.
The tools are **synchronous** (``is_async() == False``): the LLM
gets the ``timer_id`` back immediately so it can later cancel by ID.
A firing arrives as a hidden ``[System: timer X fired]`` meta message
that wakes the session on the normal ingest path.
Invoked in-process (off the runner dispatch path) these tools have no
timer registry to schedule or cancel against, so ``invoke`` validates
its arguments and then reports that no timer was scheduled or found
rather than raising.
See ``designs/SERVER_HARNESS_CONTRACT.md`` §Timers.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from omnigent.tools.base import Tool, ToolContext
_logger = logging.getLogger(__name__)
# Maximum ``seconds`` value the LLM can pass to ``sys_timer_set``.
# A pragmatic cap (~12 days) that's long enough for any realistic
# scheduling use case and short enough that an obvious typo (e.g.
# the LLM hallucinating ``seconds=99999999``) can't park a timer
# indefinitely. If a real use case exceeds this, the cap gets
# revisited together with the design tradeoffs of long-lived
# timer workflows.
_MAX_TIMER_SECONDS = 1_000_000.0
def validate_timer_set_args(
args: dict[str, Any],
) -> tuple[float, bool, str | None] | str:
"""
Validate parsed ``sys_timer_set`` arguments.
Shared by :meth:`SysTimerSetTool.invoke` and the runner's
``_execute_timer_set`` so both surfaces reject the same inputs with
identical messages and honor one delay ceiling.
:param args: JSON-decoded argument mapping, e.g.
``{"seconds": 5, "repeat": False, "note": "x"}``.
:returns: ``(seconds, repeat, note)`` when valid, otherwise an error
message naming the first invalid field, e.g.
``"seconds must be a number"``.
"""
seconds_raw = args.get("seconds")
# Reject bool explicitly: ``isinstance(True, int)`` is True, so a bare
# int/float check would silently coerce ``True`` to ``1.0``.
if not isinstance(seconds_raw, (int, float)) or isinstance(seconds_raw, bool):
return "seconds must be a number"
seconds = float(seconds_raw)
if seconds < 0:
return "seconds must be non-negative"
if seconds > _MAX_TIMER_SECONDS:
return f"seconds must be <= {_MAX_TIMER_SECONDS}"
repeat = args.get("repeat", False)
if not isinstance(repeat, bool):
return "repeat must be a boolean"
note = args.get("note")
if note is not None and not isinstance(note, str):
return "note must be a string"
return seconds, repeat, note
class SysTimerSetTool(Tool):
"""
Schedule a timer that fires a notification after a delay.
The LLM passes ``seconds`` (delay), optional ``repeat`` (default
``False``), and optional ``note`` (echoed back in each firing). On
the runner dispatch path the timer is assigned a fresh ``timer_id``
of the form ``"timer_<32-char hex>"`` and the id is returned
immediately; the firing arrives later as a hidden ``[System: timer X
fired]`` meta message that wakes the session. Repeating timers
continue until ``sys_timer_cancel`` is called.
"""
@classmethod
def name(cls) -> str:
""":returns: ``"sys_timer_set"``."""
return "sys_timer_set"
@classmethod
def description(cls) -> str:
"""
:returns: Description visible to the LLM in tool listings.
"""
return (
"Schedule a timer that fires after a delay. The firing "
"appears as a [System: timer X fired] message in the "
"conversation; you can include an optional note that's "
"echoed back in the firing. Set repeat=true for a "
"recurring timer (cancel via sys_timer_cancel). The "
"tool returns immediately with the timer_id."
)
def get_schema(self) -> dict[str, Any]:
"""
:returns: OpenAI tool schema with ``seconds`` (number,
required), ``repeat`` (boolean, optional, default
``False``), and ``note`` (string, optional).
"""
return {
"type": "function",
"function": {
"name": self.name(),
"description": self.description(),
"parameters": {
"type": "object",
"properties": {
"seconds": {
"type": "number",
"description": (
"Delay before the timer fires, in "
"seconds. Must be non-negative; the "
"first firing happens after this "
"delay. For repeat=true, also the "
"interval between firings."
),
},
"repeat": {
"type": "boolean",
"description": (
"When true, the timer fires every "
"`seconds` until cancelled. When "
"false (default), fires once."
),
"default": False,
},
"note": {
"type": "string",
"description": (
"Optional string echoed in each "
"firing's [System: timer X fired] "
"message. Useful to disambiguate "
"multiple timers."
),
},
},
"required": ["seconds"],
"additionalProperties": False,
},
},
}
def invoke(self, arguments: str, ctx: ToolContext) -> str:
"""
Validate arguments; report that the in-process path scheduled
no timer.
The firing loop runs in the runner, which intercepts
``sys_timer_set`` before this builtin is reached. When
``invoke`` does run (off the runner dispatch path) there is no
timer registry to schedule against, so it validates its input
for a consistent error surface and then returns a structured
error instead of falsely reporting success.
:param arguments: JSON-encoded args, e.g.
``'{"seconds": 5, "repeat": false, "note": "x"}'``.
:param ctx: Provides ``ctx.conversation_id`` — required so the
argument contract matches the runner path.
:returns: JSON string ``{"error": "..."}`` — either a validation
failure or a note that no timer was scheduled.
"""
try:
args = json.loads(arguments) if arguments else {}
except json.JSONDecodeError as exc:
return json.dumps({"error": f"invalid arguments: {exc}"})
validated = validate_timer_set_args(args)
if isinstance(validated, str):
return json.dumps({"error": validated})
if ctx.conversation_id is None:
# Match the runner contract: a timer needs a destination
# conversation to fire into.
return json.dumps({"error": "sys_timer_set requires a conversation context"})
return json.dumps(
{
"error": (
"sys_timer_set is executed by the runner dispatch path; this "
"in-process call cannot schedule a timer, so none was started."
)
}
)
class SysTimerCancelTool(Tool):
"""
Cancel a scheduled timer by ``timer_id``.
Cancellation is executed by the runner, which intercepts
``sys_timer_cancel`` and drops the timer from its per-session
registry. When this builtin runs in-process (off the runner
dispatch path) there is no registry to consult, so a valid
``timer_id`` reports ``status="not_found"`` — a timer that already
fired and cleaned up is indistinguishable from one that never
existed.
"""
@classmethod
def name(cls) -> str:
""":returns: ``"sys_timer_cancel"``."""
return "sys_timer_cancel"
@classmethod
def description(cls) -> str:
"""
:returns: Description visible to the LLM in tool listings.
"""
return (
"Cancel a previously scheduled timer by timer_id. "
"Returns status='cancelled' if the timer was active, "
"or status='not_found' if no such timer exists or the "
"timer has already fired and finished."
)
def get_schema(self) -> dict[str, Any]:
"""
:returns: OpenAI tool schema with ``timer_id`` (string,
required) — the value the LLM received from
``sys_timer_set``.
"""
return {
"type": "function",
"function": {
"name": self.name(),
"description": self.description(),
"parameters": {
"type": "object",
"properties": {
"timer_id": {
"type": "string",
"description": (
"The timer_id returned by sys_timer_set, e.g. 'timer_a1b2c3d4...'."
),
},
},
"required": ["timer_id"],
"additionalProperties": False,
},
},
}
def invoke(self, arguments: str, ctx: ToolContext) -> str:
"""
Report cancellation for a ``timer_id`` (in-process fallback).
The runner owns the timer registry and intercepts this tool
before the builtin is reached; this in-process path has no
registry, so a valid id reports ``not_found``.
:param arguments: JSON-encoded args, e.g.
``'{"timer_id": "timer_..."}'``.
:param ctx: Tool context (unused; cancellation is keyed on
``timer_id`` alone).
:returns: JSON string ``{"timer_id", "status": "not_found"}``,
or ``{"error": "..."}`` for invalid input.
"""
del ctx # The tool doesn't need any per-invocation context.
try:
args = json.loads(arguments) if arguments else {}
except json.JSONDecodeError as exc:
return json.dumps({"error": f"invalid arguments: {exc}"})
timer_id = args.get("timer_id")
if not isinstance(timer_id, str) or not timer_id:
return json.dumps({"error": "timer_id is required"})
# No timer registry exists on the in-process path.
return json.dumps({"timer_id": timer_id, "status": "not_found"})