Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,9 @@ and versions follow [Semantic Versioning](https://semver.org/).
before clicking, the card's small print showed the instructions written for the model, cut off mid-word ("browser:
Click the element with this ref ..."). It now shows a short plain label when there is one, like "Purchase" or
"Unlocks a door", and otherwise the card's own line (#398).
- **Changing a watch in chat sticks.** Sentient uses the newest request about what to report: "any change" hears
about every update, while "only important changes" switches back to the quieter check. Ordinary edits such as
"change it to every 30 minutes" do not count as a request for every update (#370).
- **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).
Expand Down
46 changes: 34 additions & 12 deletions sentient/tasks/scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,16 +499,35 @@ def normalize_script(
return out


# the user asks to hear about a change itself ("when it changes", "anything", "everything"): no AI check decides that.
# Not a bare "every": "every 10 minutes" is a schedule. "important changes" (group 1) asks for the AI check.
_ANY_CHANGE = re.compile(
r"\b(?:(important|big|major|significant|meaningful)\s+)?chang(?:e|es|ed|ing)\b|\bany\s?thing\b|\bevery\s?thing\b",
re.IGNORECASE,
# The newest request about changes wins. A bare "change" can be a verb ("change it to every 30 minutes"), so it
# is removed before looking for an update preference. "Anything else" is deliberately not a preference either.
_IMPORTANT_CHANGE = re.compile(
r"\b(?:only\s+)?(?:important|big|major|significant|meaningful)\s+(?:\w+\s+)?changes?\b", re.IGNORECASE,
)
_CHANGE_AS_EDIT = re.compile(
r"\bchange\s+(?:it|this|that|the\b|my\b|your\b|a\b|an\b)|\bchange\s+my\s+mind\b", re.IGNORECASE,
)
_CHANGE = re.compile(r"\bchang(?:e|es|ed|ing)\b", re.IGNORECASE)
_ALL_UPDATES = re.compile(r"\b(?:anything|everything)\s+(?:at\s+all|new)\b", re.IGNORECASE)


def _asks_for_any_change(request: str) -> bool:
return any(not m.group(1) for m in _ANY_CHANGE.finditer(request or ""))
def _change_preference(message: str) -> str | None:
"""``changed`` or ``noteworthy`` when one user message says which updates to report."""
if _IMPORTANT_CHANGE.search(message):
return "noteworthy"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
without_edits = _CHANGE_AS_EDIT.sub("", message)
if _CHANGE.search(without_edits) or _ALL_UPDATES.search(message):
return "changed"
return None


def _latest_change_preference(request: str | list[str]) -> str | None:
messages = request.split("\n") if isinstance(request, str) else request
for message in reversed(messages):
preference = _change_preference(str(message or ""))
if preference is not None:
return preference
return None


def _is_fetch(node: ast.AST, pages: set[str]) -> bool:
Expand Down Expand Up @@ -547,14 +566,17 @@ def returns_whole_page(code: str) -> bool:
)


def planner_default(script: dict, request: str, previous: dict | None = None) -> dict:
def planner_default(script: dict, request: str | list[str], previous: dict | None = None) -> dict:
"""A ``changed`` check the planner proposes that returns a whole fetched page becomes ``noteworthy`` (#298): a
view counter or a timestamp there would notify on every check. One value taken out of the page, a search, a
request that talks about a change ("changes", "anything", "everything", but not "important changes") and a task
already on ``changed`` keep ``changed``."""
if script.get("condition") != "changed" or (previous or {}).get("condition") == "changed":
request whose newest update preference is every change keeps ``changed``. A newer request for important changes
switches an existing ``changed`` task back to ``noteworthy``."""
if script.get("condition") != "changed":
return script
if not returns_whole_page(script.get("code") or ""):
return script
if _asks_for_any_change(request) or not returns_whole_page(script.get("code") or ""):
preference = _latest_change_preference(request)
if preference == "changed" or (preference is None and (previous or {}).get("condition") == "changed"):
return script
return {**script, "condition": "noteworthy"}

Expand Down
7 changes: 4 additions & 3 deletions sentient/tasks/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1599,20 +1599,21 @@ def _web_watch_schedule(
return sched

@staticmethod
def _script_request(task: dict) -> str:
def _script_request(task: dict, *, separate: bool = False) -> str | list[str]:
"""What the user asked for, for the planner's script checks (#375): the request, what they said in the task's
chat since and their answers to clarifying questions."""
said = [str(task.get("original_prompt") or task.get("description") or task.get("name") or "")]
said += [str(m.get("content") or "") for m in task.get("chat_history") or []
if isinstance(m, dict) and m.get("role") == "user"]
said += [str(q.get("answer") or "") for q in task.get("clarifying_questions") or [] if isinstance(q, dict)]
return "\n".join(s for s in said if s.strip())
said = [s for s in said if s.strip()]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return said if separate else "\n".join(said)

@staticmethod
def _default_condition(task: dict, script: dict) -> dict:
"""A page watch the planner left on plain ``changed`` becomes ``noteworthy`` (#298, ``scripts.planner_default``)."""
previous = task.get("script") if task.get("task_type") == "script" else None
return scripts.planner_default(script, str(task.get("original_prompt") or ""), previous)
return scripts.planner_default(script, TaskService._script_request(task, separate=True), previous)

def _no_script_plan(self, task: dict, script_tools: list[str], planned: list[dict]) -> list[dict]:
"""The plan of an AI-run task that replaces a rejected check script (#310): the user's own request as the step,
Expand Down
51 changes: 50 additions & 1 deletion tests/tasks/test_scripts_triggers_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ async def test_planner_produces_script_job(make_app):
"noteworthy"),
# "important changes" asks for the AI check, not for every change
*[(f"Tell me about {q} changes on https://example.org/events", PAGE_CODE, "changed", "noteworthy")
for q in ("important", "big", "major", "significant", "meaningful")],
for q in ("important", "big", "major", "significant", "meaningful", "important page", "significant price")],
# the user wants every change: "changed" is kept, even for a whole page
("Tell me whenever anything at all on this page changes: https://example.org/events", PAGE_CODE, "changed",
"changed"),
Expand Down Expand Up @@ -359,6 +359,55 @@ async def test_planner_watch_defaults_whole_pages_to_important_changes(make_app,
assert 'If the user wants to hear about any change at all, use "changed".' in prompt


@pytest.mark.parametrize(("role", "stored"), [("user", "changed"), ("assistant", "noteworthy")])
async def test_planner_watch_default_uses_user_chat(make_app, role, stored):
watch = {**PLAN_SCRIPT, "script": {"code": PAGE_CODE, "condition": "changed", "then": "notify"}}
app = await make_app(FakeProvider(json_replies=[watch]))
task_id = await _script_task(
app, condition="noteworthy", code=PAGE_CODE,
original_prompt="Watch this page for new events: https://example.org/events",
chat_history=[{"role": "assistant", "content": "Tell me about any change at all."}],
)
if role == "user":
await app.tasks.chat(task_id, "Actually tell me about any change at all on that page")
else:
await app.tasks.chat(task_id, "Check every ten minutes instead")
await app.tasks.drain()
task = await app.tasks.get(task_id)
assert task["status"] == "approval_pending"
assert task["script"]["condition"] == stored


@pytest.mark.parametrize("message", [
"Change it to check every 30 minutes",
"Change the time to every 30 minutes",
"Thanks for everything",
"Anything else you need from me?",
])
async def test_planner_watch_default_ignores_edits_and_unrelated_chat(make_app, message):
watch = {**PLAN_SCRIPT, "script": {"code": PAGE_CODE, "condition": "changed", "then": "notify"}}
app = await make_app(FakeProvider(json_replies=[watch]))
task_id = await _script_task(
app, condition="noteworthy", code=PAGE_CODE,
original_prompt="Watch this page for new events: https://example.org/events",
)
await app.tasks.chat(task_id, message)
await app.tasks.drain()
assert (await app.tasks.get(task_id))["script"]["condition"] == "noteworthy"


async def test_planner_watch_default_uses_newest_change_preference(make_app):
watch = {**PLAN_SCRIPT, "script": {"code": PAGE_CODE, "condition": "changed", "then": "notify"}}
app = await make_app(FakeProvider(json_replies=[watch]))
task_id = await _script_task(
app, condition="changed", code=PAGE_CODE,
original_prompt="Watch this page for any change at all: https://example.org/events",
)
await app.tasks.chat(task_id, "Only important changes please")
await app.tasks.drain()
assert (await app.tasks.get(task_id))["script"]["condition"] == "noteworthy"


FETCH = "from sentient_tools import tools, result\npage = tools.web_fetch(url='https://example.org')\n"


Expand Down
Loading