Something a model could not do, and so wrote a note about instead

Asked to remind somebody every Monday, a model looked down its tool list, found
notes_create described as "something worth having in a later conversation" and
memory_add beginning with the word Remember, wrote a note, and reported that it
had scheduled something. Every screen agreed with it. There was no scheduling
tool at all -- the near-misses were the only thing there was to reach for, and
nothing anywhere said the thing it was being asked for existed.

The seam had been left open on purpose: Schedule.origin has defined
ORIGIN_MODEL, with no writer, since scheduling shipped, and services/schedules.py
says in its first line that it holds what the routes *and the tools* both need.
This is the tool that was meant to go through it.

Four of them, and a thin layer: rule.validate is still the one total normaliser
the form and the compile share, schedules.create still writes the row and the
task chat together, and rule.describe still says what came out. A second dialect
for models would mean two definitions of "every other Tuesday" and one of them
going quietly wrong.

The result is that description, never "done". A schedule is invisible until it
fires, which may be days away, so the sentence in the reply is the only moment
anybody can check that Monday was read as Monday -- and the tool says so, in the
text the model reads back. The list badges the ones nobody typed.

Gated on schedule.use rather than a permission of its own: somebody who may set
one up by hand may say so to a model instead, and a second checkbox beside the
first would only ever be answered "the same as that one".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 10:28:05 +02:00
co-authored by Claude Opus 5
parent 09156230b3
commit 9761082fa1
7 changed files with 894 additions and 5 deletions
+52 -2
View File
@@ -130,6 +130,13 @@ FAMILY_IMAGE = "image"
# must not hand out the notebook.
FAMILY_REPORT = "report"
# Setting work up to happen later, or repeatedly. Its own family and emphatically
# not part of `notes`: the line between them is the whole reason this exists. A
# note is something to find again; a schedule is something that *happens*, and a
# model with only the first reached for it when asked for the second -- wrote the
# note, said it had scheduled something, and nothing anywhere disagreed.
FAMILY_SCHEDULE = "schedule"
# The built-in families, in the order they are offered.
FAMILIES = (
FAMILY_SEARCH,
@@ -142,6 +149,7 @@ FAMILIES = (
FAMILY_ASK,
FAMILY_IMAGE,
FAMILY_REPORT,
FAMILY_SCHEDULE,
FAMILY_AGENT,
)
@@ -1286,7 +1294,13 @@ REGISTRY: dict[str, ToolDef] = {
def _family_allowed(
family: str, *, config: dict, capabilities: dict, allowed: dict, images: bool = False
family: str,
*,
config: dict,
capabilities: dict,
allowed: dict,
images: bool = False,
schedules: bool = False,
) -> bool:
"""Whether one family is on for this chat.
@@ -1319,6 +1333,15 @@ def _family_allowed(
# the shape `resolve_tools` already refuses for `skill_get` with an
# empty library. `settings_store.images_ready` answers all three.
return bool(allowed.get("tools.image") and images)
if gate == FAMILY_SCHEDULE:
# `schedule.use` rather than a `tools.schedule` of its own: a reader who
# may set a schedule up by hand may say so to a model instead, and a
# second permission beside the first would only ever be answered "the
# same as that one". `schedules` is the instance switch, passed in for
# the reason `images` is -- an instance with scheduling off must not
# offer this at all, or a model spends a round being told the tool it
# was handed does not work.
return bool(allowed.get("schedule.use") and schedules)
if gate in (
FAMILY_CUSTOM,
FAMILY_MCP,
@@ -1375,6 +1398,20 @@ def _agent_defs(db: DBSession, chat: Chat | None, user: User | None) -> list[Too
return agent_tools.tool_defs(context)
def _schedule_defs() -> list[ToolDef]:
"""The scheduling tools.
Not in `REGISTRY` even though they need no rows and no settings to build,
because the module they live in imports `services/tools.py` for `ToolDef`
and the risk constants -- so importing it back at module scope is a cycle.
A function keeps the import inside the call, which is the same shape
`_agent_defs` and `_image_defs` already have.
"""
from lembas.services.schedule import tool as schedule_tool
return schedule_tool.tool_defs()
def _image_defs(db: DBSession, values: dict | None = None) -> list[ToolDef]:
"""The image tool, whose schema carries this instance's own choices.
@@ -1417,9 +1454,19 @@ def registry(db: DBSession) -> dict[str, ToolDef]:
working on. The same omission cost custom tools their guidance once already.
"""
from lembas.services.agent import tools as agent_tools
from lembas.services.schedule import tool as schedule_tool
return _book(
[*_row_defs(db, None, everything=True), *agent_tools.tool_defs(), *_image_defs(db)]
[
*_row_defs(db, None, everything=True),
*agent_tools.tool_defs(),
*_image_defs(db),
# Listed here, ungated, or `harness._families` cannot map
# `schedule_create` back to a family and the guidance never
# reaches the model. That omission has cost two features their
# instructions already.
*schedule_tool.tool_defs(),
]
)
@@ -1446,6 +1493,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
config = settings_store.search(db)
image_values = settings_store.images(db)
images_ready = settings_store.images_ready(db)
schedules_on = bool(settings_store.schedules(db).get("enabled"))
# Resolved against what this reader may see, not against everything that
# exists: a tool restricted to a group is not offered outside it. The image
@@ -1457,6 +1505,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
*_row_defs(db, user),
*_agent_defs(db, chat, user),
*(_image_defs(db, image_values) if images_ready else []),
*(_schedule_defs() if schedules_on else []),
]
)
@@ -1490,6 +1539,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
capabilities=capabilities,
allowed=allowed,
images=images_ready,
schedules=schedules_on,
)
and gate_of(tool.family) not in off
# Nothing to read and nothing to improve. Offering `skill_get` with