Skip to content
3 changes: 3 additions & 0 deletions changes/370.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**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.
47 changes: 36 additions & 11 deletions sentient/tasks/scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,16 +499,38 @@ 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",
# The newest request about updates wins. A question about a page is not a request to change its watch setting;
# neither is a bare use of "change" as a verb. Requests for every update must say that positively.
_NOTEWORTHY_REQUEST = re.compile(
r"\b(?:only\s+)?(?:important|big|major|significant|meaningful)\s+(?:\w+\s+)?(?:changes?|updates?|stuff)\b"
r"|\b(?:ignore|skip|don't|do not)\s+(?:\w+\s+)?(?:minor|small|little)\s+(?:changes?|updates?)\b",
re.IGNORECASE,
)
_EVERY_CHANGE_REQUEST = re.compile(
r"\b(?:any|every|all)\s+(?:\w+\s+){0,3}?(?:changes?|updates?)\b"
r"|\bwhenever\b[^.?!]{0,80}\b(?:changes?|updates?)\b"
r"|\b(?:tell me|notify me|let me know|alert me|report)\b[^.?!]{0,80}\b(?:changes?|updates?)\b"
r"|\b(?:tell me|notify me|let me know|alert me|report)\b[^.?!]{0,80}\b(?:anything|everything)\s+(?:at\s+all|new)\b",
re.IGNORECASE,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)


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 message.rstrip().endswith("?"):
return None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
preferences = [(match.start(), "noteworthy") for match in _NOTEWORTHY_REQUEST.finditer(message)]
preferences += [(match.start(), "changed") for match in _EVERY_CHANGE_REQUEST.finditer(message)]
return max(preferences, default=(0, None), key=lambda preference: preference[0])[1]


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 +569,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
9 changes: 5 additions & 4 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(q.get("answer") or "") for q in task.get("clarifying_questions") or [] if isinstance(q, dict)]
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
91 changes: 90 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,95 @@ 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"


def test_planner_default_uses_the_last_preference_in_one_message():
changed = normalize_script({"code": PAGE_CODE, "condition": "changed"})
assert scripts.planner_default(
changed, "Only important changes — actually, tell me about any change.",
)["condition"] == "changed"
assert scripts.planner_default(
changed, "Tell me about any change — only important changes, please.",
)["condition"] == "noteworthy"


@pytest.mark.parametrize(("message", "preference"), [
("Has anything changed on that page?", None),
("What changed on the page?", None),
("Change to every hour", None),
("I changed my mind about the watch", None),
("Change of plans", None),
("Can you change how often it checks?", None),
("Tell me about any change", "changed"),
("Let me know whenever the page updates", "changed"),
("Only important updates", "noteworthy"),
("Only tell me about important stuff. Schedule changes are fine", "noteworthy"),
("Ignore minor changes, I only care about new events", "noteworthy"),
])
def test_change_preference_requires_an_explicit_watch_request(message, preference):
assert scripts._change_preference(message) == preference


async def test_planner_watch_default_gives_later_chat_priority_over_answers(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="noteworthy", code=PAGE_CODE,
original_prompt="Watch this page for new events: https://example.org/events",
clarifying_questions=[{"question": "What should I report?", "answer": "Only important changes"}],
)
await app.tasks.chat(task_id, "Actually, tell me about any change.")
await app.tasks.drain()
assert (await app.tasks.get(task_id))["script"]["condition"] == "changed"


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


Expand Down
Loading