Repository navigation
fix(tasks): a page watch never keeps a trigger it has no use for #394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a6f69cf
e4e7d82
fda4903
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
Comment on lines
+1584
to
+1587
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1425,1575p' sentient/tasks/service.py
sed -n '1573,1605p' sentient/tasks/service.py
sed -n '2665,2690p' docs/API.mdRepository: existence-master/Sentient Length of output: 14640 🏁 Script executed: printf '%s\n' '--- service fallback and planner normalization ---'
rg -n -F -- '_no_script_plan' sentient/tasks/service.py
rg -n -F -- 'def normalize_plan' sentient/tasks/service.py sentient/tasks
rg -n -F -- 'def polls_the_web' sentient/tasks/scripts.py
sed -n '130,180p' sentient/tasks/service.py
sed -n '500,560p' sentient/tasks/service.py
sed -n '380,415p' sentient/tasks/scripts.py
printf '%s\n' '--- schedule tests and plan assertions ---'
rg -n -F -- 'web_watch_schedule' tests sentient || test "$?" -eq 1
rg -n -F -e 'task_type' -e 'web_fetch' -e 'script_tools' tests/tasks/test_watch_schedule.py || true
sed -n '1,260p' tests/tasks/test_watch_schedule.pyRepository: existence-master/Sentient Length of output: 12599 🏁 Script executed: sed -n '1608,1655p' sentient/tasks/service.py
printf '%s\n' '--- web tool and plugin declarations ---'
rg -n -F -e 'web_fetch' -e 'web_search' -e 'class Web' -e 'id = "web"' sentient sentient_tools tests || test "$?" -eq 1
printf '%s\n' '--- fallback-plan and planner-shape tests ---'
rg -n -F -e 'couldn'\''t write a working check script' -e 'task_type"] == "single"' -e 'script": None' -e '_no_script_plan' tests sentient || test "$?" -eq 1Repository: existence-master/Sentient Length of output: 19769 🏁 Script executed: sed -n '1,155p' sentient/integrations/plugins/search.py
printf '%s\n' '--- other news declarations ---'
rg -n -F -e 'news_top_headlines' -e 'news_search' -e 'class News' sentient/integrations sentient
printf '%s\n' '--- registry interfaces ---'
rg -n -F -e 'class ToolRegistry' -e 'def tools(' -e 'def plugins(' -e 'class ToolPlugin' sentient | head -80Repository: existence-master/Sentient Length of output: 7704 Include normalized planner tools in the fallback web check. When both scripts are rejected, Pass the normalized plan to Suggested fix- repaired = self._web_watch_schedule(
- fields.get("schedule") or schedule, script, script_tools, task, data.get("schedule"), tz
- )
+ repaired = self._web_watch_schedule(
+ fields.get("schedule") or schedule, script, script_tools, plan, task, data.get("schedule"), tz
+ )
def _web_watch_schedule(
- self, schedule: dict, script: dict | None, script_tools: list[str] | None, task: dict, proposed: Any, tz: str,
+ self, schedule: dict, script: dict | None, script_tools: list[str] | None, plan: list[dict],
+ task: dict, proposed: Any, tz: str,
) -> dict | None:
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 [])
+ if script is None:
+ planned_plugins = {step.get("tool") for step in plan}
+ tools.extend(
+ tool.name for tool in self.app.registry.tools()
+ if tool.name in scripts.WEB_TOOLS and tool.plugin in planned_plugins
+ )
request = self._script_request(task)🤖 Prompt for AI Agents |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
Uh oh!
There was an error while loading. Please reload this page.