diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd71fb1..63378b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -411,6 +411,9 @@ and versions follow [Semantic Versioning](https://semver.org/). - A task that repeats the same failing step three times now pauses as stuck and asks what to do, instead of failing. ### Fixed +- **A page watch always checks the page.** "Track this page and tell me when a new event is added" could be set to + start only when something arrived in your calendar or smart home, so it never looked at the page. A check that reads + a page, a search or the news now runs on a timer: every hour, or as often as you asked (#389). - **Changing a rule while an email waits to go out now counts.** If you set an app or tool to **Ask** while one of its emails or messages waits the few seconds before it is sent, Sentient asks you before it goes out instead of sending it under the old rule. The same goes for **Only if I asked**, unless your message asked for that email. diff --git a/docs/API.md b/docs/API.md index 619d5d12..4c85b94c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -781,7 +781,7 @@ Each run embeds its most recent 200 progress updates; the full log is at the `/e `interval` schedules (every `interval_minutes`, minimum 5; `frequency: "hourly"` is normalized to 60) are additive. The task-creation prompt produces them for repeats measured in minutes or hours, and a request that says so ("every 10 minutes", "every hour", "every 2 hours", "every 30 min", "hourly") is always saved as `interval`, whatever the model -wrote (triggered schedules stay triggered). The planner also uses `interval` for script jobs. +wrote (triggered schedules stay triggered, except a web check's, section 16). The planner also uses `interval` for script jobs. **Fuzzy windows:** a daily or weekly schedule with `window` runs at a random minute inside that part of the day, in its timezone, instead of at `time` (kept for the editor): `morning` 07:00-10:00, `afternoon` 12:00-16:00, `evening` 17:00-20:00, `night` 20:00-23:00. `next_execution_at` is the next window that has not started yet (so a run inside @@ -2677,6 +2677,11 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a for this, so the AI will do it at each run instead."). Its runs use the recurring-task memory ("Recurring tasks remember"). The same rules apply to script code changed by chat tools; code a person edits (`PATCH /api/tasks/{id}`) only has to compile. A script job the planner leaves on an immediate one-off schedule becomes `interval` 60. + A script job (or the AI-run task that replaced one) that reads the web (`web_fetch`, `web_search`, `news_search`, + `news_top_headlines`) never keeps a `triggered` schedule it has no use for (#389): unless the code reads + `trigger_event` or the request names the trigger's app ("email", "inbox", "calendar", "meeting", "door", "lights", + "webhook"...), it becomes recurring: the interval the request says, else the planner's daily or weekly schedule, + else `interval` 60. The planner is told to use `noteworthy` by default for a page, a feed or the news, and `changed` for one exact value or when the user wants to hear about any change at all (#298). A `changed` script it proposes that returns the whole result of `tools.web_fetch` (the page or its `content`) is stored as `noteworthy`, unless the request talks about a diff --git a/sentient/tasks/scripts.py b/sentient/tasks/scripts.py index e32a6984..cb28bf6c 100644 --- a/sentient/tasks/scripts.py +++ b/sentient/tasks/scripts.py @@ -372,6 +372,46 @@ def _check_whole_page(tree: ast.AST) -> None: ) +# Tools that read the web: a script using them polls a page, a search or the news (#389). +WEB_TOOLS = ("web_fetch", "web_search", "news_top_headlines", "news_search") +# Words that name a trigger's app in a request: "when I get an email", "when a meeting is added to my calendar". +_SOURCE_WORDS: dict[str, re.Pattern] = { + "gmail": re.compile(r"\b(?:e-?mails?|mail|inbox|gmail)\b", re.IGNORECASE), + "email_imap": re.compile(r"\b(?:e-?mails?|mail|inbox)\b", re.IGNORECASE), + "gcalendar": re.compile(r"\b(?:calendars?|meetings?|appointments?)\b", re.IGNORECASE), + "home_assistant": re.compile( + r"\b(?:home\s+assistant|smart\s+home|doors?|lights?|locks?|sensors?|thermostats?|garage|alarms?|switch(?:es)?)\b", + re.IGNORECASE), + "webhook": re.compile(r"\b(?:webhooks?|hooks?)\b", re.IGNORECASE), +} + + +def _reads_trigger_event(code: str) -> bool: + """The code reads the ``trigger_event`` variable; a comment or a string that only mentions it does not count.""" + try: + tree = ast.parse(code or "") + except SyntaxError: + return False + return any(isinstance(node, ast.Name) and node.id == "trigger_event" and isinstance(node.ctx, ast.Load) + for node in ast.walk(tree)) + + +def polls_the_web(tools: list[str]) -> bool: + """A script (or the AI-run task that replaced one) that reads pages, a search or the news.""" + return any(t in WEB_TOOLS for t in tools) + + +def trigger_is_real(schedule: dict, code: str, request: str) -> bool: + """Whether a ``triggered`` schedule can be what a web check runs on (#389): the script reads the event that + started it (``trigger_event``), or the user's request names the trigger's app ("when I get an email"). Small + models give a page watch like "tell me when a new event is added" a Google Calendar trigger, so it would only + run when a calendar event arrives and never look at the page.""" + if _reads_trigger_event(code): + return True + words = _SOURCE_WORDS.get(str(schedule.get("source") or "").strip().lower()) + return bool(words and words.search(request or "")) + + def check_rules(code: str, condition: str, request: str = "") -> None: """Reject what small models write into check scripts that cannot work (#305), with a plain reason the planner can act on: a package that is not installed, an endless loop, a wait, memory calls, an alert result on a diff --git a/sentient/tasks/service.py b/sentient/tasks/service.py index c545d1e7..8dd7fda9 100644 --- a/sentient/tasks/service.py +++ b/sentient/tasks/service.py @@ -62,7 +62,9 @@ normalize_schedule, parse_iso, parse_run_at, + spoken_interval, spoken_schedule, + spoken_window, user_timezone_name, ) from sentient.tasks.scripts import ScriptInvalid, normalize_script, tools_named, validate_code @@ -1490,6 +1492,12 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None: # a watch job that would check only once is almost never what was meant sched = normalize_schedule({"type": "recurring", "frequency": "interval", "interval_minutes": 60}, tz) fields["schedule"] = sched + if script is not None or no_script: + repaired = self._web_watch_schedule( + fields.get("schedule") or schedule, script, script_tools, task, data.get("schedule"), tz + ) + if repaired is not None: + fields["schedule"] = repaired if is_change: fields["chat_history"] = [*history, { "role": "assistant", @@ -1562,6 +1570,34 @@ async def _planned_script( fixed = {**fixed, "schedule": data["schedule"]} return None, fixed, tools_named("\n".join(codes)) + def _web_watch_schedule( + self, schedule: dict, script: dict | None, script_tools: list[str] | None, task: dict, proposed: Any, tz: str, + ) -> dict | None: + """A recurring schedule for a web check stuck on a trigger it has nothing to do with (#389), else None. + + The check reads a page, a search or the news, so it must run on a timer to notice anything; a ``triggered`` + schedule (a small model's guess for "tell me when a new event is added") would only run it when, say, a + calendar event arrives. A trigger the script reads, or one the request names, stays. The repair uses the + interval the user said, else the planner's daily or weekly schedule, else every hour.""" + if (schedule or {}).get("type") != "triggered": + return None + code = str((script or {}).get("code") or "") + tools = tools_named(code) if script is not None else list(script_tools or []) + request = self._script_request(task) + if not scripts.polls_the_web(tools) or scripts.trigger_is_real(schedule, code, request): + return None + minutes = spoken_interval(request) + planned = normalize_schedule(proposed, tz) if isinstance(proposed, dict) and proposed else None + if minutes is not None: + sched = normalize_schedule({"type": "recurring", "frequency": "interval", "interval_minutes": minutes}, tz) + elif planned is not None and planned["type"] == "recurring" and planned["frequency"] != "interval": + sched = spoken_window(planned, request) # "every morning": the planner's daily or weekly schedule + else: # an interval nobody asked for (small models pick 5 minutes) or none: every hour + sched = normalize_schedule({"type": "recurring", "frequency": "interval", "interval_minutes": 60}, tz) + log.info("web check for task %s had a %s trigger it never uses, checking on a timer instead", + task["id"], schedule.get("source") or "blank") + return sched + @staticmethod def _script_request(task: dict) -> str: """What the user asked for, for the planner's script checks (#375): the request, what they said in the task's diff --git a/tests/tasks/test_watch_schedule.py b/tests/tasks/test_watch_schedule.py new file mode 100644 index 00000000..19f63c0f --- /dev/null +++ b/tests/tasks/test_watch_schedule.py @@ -0,0 +1,87 @@ +"""#389: a check script that reads the web runs on a timer. A `triggered` schedule it never uses (a small model's guess +for "tell me when a new event is added") is repaired to a recurring one in planning.""" + +from __future__ import annotations + +import pytest + +from sentient.tasks.scripts import trigger_is_real +from tests.conftest import FakeProvider +from tests.tasks.conftest import PLAN_TIME + +FETCH = "from sentient_tools import tools, result\npage = tools.web_fetch(url='http://192.0.2.1/events.html')\n" +WHOLE = FETCH + "result(page.get('content', ''))\n" +WATCH = "Track http://192.0.2.1/events.html and tell me when a new event is added" +CALENDAR = {"type": "triggered", "source": "gcalendar", "event": "new_event", "filter": {}} +HOURLY = {"type": "recurring", "frequency": "interval", "interval_minutes": 60} + + +def refined(schedule: dict) -> dict: + return {"name": "Events watch", "description": WATCH, "priority": 1, "schedule": schedule} + + +def planned(code: str = WHOLE, **extra) -> dict: + return {**PLAN_TIME, "script": {"code": code, "condition": "noteworthy", "then": "notify"}, **extra} + + +async def plan(make_app, prompt: str, *replies: dict) -> dict: + app = await make_app(FakeProvider(json_replies=list(replies))) + created = await app.tasks.create_task(prompt) + await app.tasks.drain() + return await app.tasks.get(created["task_id"]) + + +@pytest.mark.parametrize(("schedule", "code", "request_text", "real"), [ + (CALENDAR, WHOLE, WATCH, False), + ({"type": "triggered", "source": "custom", "event": "", "filter": {}}, WHOLE, WATCH, False), + ({"type": "triggered", "source": "", "event": "", "filter": {}}, WHOLE, WATCH, False), + (CALENDAR, WHOLE, "When a meeting is added to my calendar, check http://192.0.2.1/room.html", True), + ({"type": "triggered", "source": "gmail", "event": "new_email", "filter": {}}, WHOLE, + "Whenever I get an email from the bank, check http://192.0.2.1/status.html", True), + (CALENDAR, FETCH + "result([trigger_event.get('summary'), page['content']])\n", WATCH, True), + (CALENDAR, "# no trigger_event here\n" + WHOLE + "print('trigger_event')\n", WATCH, False), # only mentioned + (CALENDAR, FETCH + "trigger_event(\n", WATCH, False), # does not compile +]) +def test_a_trigger_is_real_when_the_script_reads_it_or_the_request_names_its_app(schedule, code, request_text, real): + assert trigger_is_real(schedule, code, request_text) is real + + +async def test_a_calendar_trigger_on_a_page_watch_becomes_hourly(make_app): + task = await plan(make_app, WATCH, refined(CALENDAR), planned()) + assert task["task_type"] == "script" and task["status"] == "approval_pending" + assert task["schedule"]["type"] == "recurring" + assert (task["schedule"]["frequency"], task["schedule"]["interval_minutes"]) == ("interval", 60) + + +async def test_the_repair_keeps_the_interval_the_user_said(make_app): + prompt = "Check http://192.0.2.1/events.html every 30 minutes and tell me when a new event is added" + task = await plan(make_app, prompt, refined(CALENDAR), planned()) + assert (task["schedule"]["type"], task["schedule"]["interval_minutes"]) == ("recurring", 30) + + +async def test_the_repair_takes_the_planners_daily_schedule_or_else_hourly(make_app): + once = {"type": "once", "run_at": None} + task = await plan(make_app, WATCH, refined(once), planned(schedule={**CALENDAR, "source": "custom"})) + assert task["schedule"]["type"] == "recurring" and task["schedule"]["interval_minutes"] == 60 + + unasked = {"type": "recurring", "frequency": "interval", "interval_minutes": 5} # what qwen3:8b proposed + task = await plan(make_app, WATCH, refined(CALENDAR), planned(schedule=unasked)) + assert task["schedule"]["type"] == "recurring" and task["schedule"]["interval_minutes"] == 60 + + daily = {"type": "recurring", "frequency": "daily", "time": "08:00"} + task = await plan(make_app, WATCH, refined(CALENDAR), planned(schedule=daily)) + assert (task["schedule"]["type"], task["schedule"]["frequency"], task["schedule"]["time"]) == ( + "recurring", "daily", "08:00") + + +async def test_a_trigger_the_user_asked_for_stays(make_app): + prompt = "Whenever I get an email from the bank, check http://192.0.2.1/status.html and tell me what it says" + gmail = {"type": "triggered", "source": "gmail", "event": "new_email", "filter": {"from": "bank@example.com"}} + task = await plan(make_app, prompt, refined(gmail), planned()) + assert task["schedule"]["type"] == "triggered" and task["schedule"]["source"] == "gmail" + + +async def test_a_script_that_reads_no_web_keeps_its_trigger(make_app): + code = "from sentient_tools import tools, result\nresult(trigger_event.get('summary'))\n" + task = await plan(make_app, "Tell me when a new event is added", refined(CALENDAR), planned(code=code)) + assert task["schedule"]["type"] == "triggered"