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:
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.8.3"
|
||||
__version__ = "0.9.0"
|
||||
|
||||
@@ -988,7 +988,9 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"a summary of a long document. Correct one with notes_edit when it turns out "
|
||||
"to be wrong, and remove it with notes_delete when it is no longer true — a "
|
||||
"stale note is worse than no note. Anything short and durable about the "
|
||||
"person themselves is a memory rather than a note."
|
||||
"person themselves is a memory rather than a note, and anything that should "
|
||||
"*happen* at a time — later, tomorrow, every week — is a schedule rather than "
|
||||
"either, because a note does nothing at the time it describes."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1014,7 +1016,9 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"health details they have not asked you to keep. Anything longer than a "
|
||||
"sentence, or about the work rather than about them, does not belong here. "
|
||||
"When something you remembered turns out to be wrong, remove it with "
|
||||
"memory_forget, quoting it in full, rather than adding a correction beside it."
|
||||
"memory_forget, quoting it in full, rather than adding a correction beside it. "
|
||||
"“Remind me to…” is not a memory: remembering that something should happen "
|
||||
"does not make it happen, and a schedule does."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1216,6 +1220,48 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"recurring report so this one can say what changed."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.schedule",
|
||||
label="Scheduling",
|
||||
group=GROUP_TOOLS,
|
||||
order=248,
|
||||
families=("schedule",),
|
||||
variables=("now",),
|
||||
hint="Appears when the scheduling tools are offered. Most of this is the "
|
||||
"rule vocabulary, which is also in the tool's own schema — repeated "
|
||||
"here because the failure it prevents is expensive and silent: a "
|
||||
"schedule that names the wrong day looks exactly like a working one on "
|
||||
"every screen, and nobody finds out until it fires. The opening "
|
||||
"sentence is the one that matters most, and it is here because of what "
|
||||
"happened without it: asked to schedule something, a model wrote a "
|
||||
"note, because a note was the nearest thing in its tool list and "
|
||||
"nothing said scheduling existed.",
|
||||
default=(
|
||||
"- You can make things happen later. schedule_create sets up work that runs "
|
||||
"because time has passed rather than because somebody asked just now — once, "
|
||||
"or on a repeat. Use it whenever the person says *when*: “in ten minutes”, "
|
||||
"“every Monday at noon”, “each morning”, “remind me”. Writing a note or a "
|
||||
"memory instead makes nothing happen at the time; those are read only when "
|
||||
"somebody goes looking. schedule_list shows what already exists, "
|
||||
"schedule_update changes one and schedule_cancel stops it.\n"
|
||||
"- It is {{now}} where this person is, and every time you write is read in "
|
||||
"their zone. Work “in ten minutes” and “tomorrow at nine” out from that clock "
|
||||
"rather than guessing.\n"
|
||||
'- Use "every" for a plain timer and "at" for a calendar: "in ten minutes" is '
|
||||
'{"every": {"minutes": 10}} with a start, and "every Monday at noon" is '
|
||||
'{"at": {"weekdays": [0], "times": ["12:00"]}}. Monday is 0 — count the days '
|
||||
"off rather than guessing, because naming the wrong one still looks like a "
|
||||
"working schedule.\n"
|
||||
"- Choose where the result goes. A reminder or a short daily fact goes to "
|
||||
"messages; something to read and keep goes to a report; work that builds on "
|
||||
"the previous run stays in its own chat.\n"
|
||||
"- Write the instruction so it stands alone. It is read days later by a model "
|
||||
"that was not here, with nobody to ask what you meant.\n"
|
||||
"- Say the resulting timing back in your reply — the tool gives it to you in "
|
||||
"words. That sentence is the only chance the person has to notice a mistake "
|
||||
"before the first run."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="context.knowledge_scope",
|
||||
label="Which knowledge bases",
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Scheduling, as something a model can do rather than only a person.
|
||||
|
||||
## Why this exists
|
||||
|
||||
It did not, and that was the bug. Asked to "remind me every Monday at noon", 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. Nothing
|
||||
in the list said scheduling existed; nothing said it did not. The near-misses
|
||||
were the only thing to reach for.
|
||||
|
||||
No prompt fixes that. The seam had been left open on purpose --
|
||||
`Schedule.origin` has defined `ORIGIN_MODEL` since the feature landed, with no
|
||||
writer, and `services/schedules.py` says in its first line that it holds "what
|
||||
the routes **and the tools** both need" -- and this is the tool that was meant
|
||||
to go through it.
|
||||
|
||||
## One vocabulary, not a second one
|
||||
|
||||
Everything below is a thin layer over what the form already uses:
|
||||
`rule.validate` is the single total normaliser (the manual form, the compile
|
||||
step and this all hand it the same raw shape), `schedules.create` writes the row
|
||||
and the task chat together, and `rule.describe` says what came out in words. A
|
||||
second dialect for models would mean two definitions of "every other Tuesday"
|
||||
and one of them going quietly wrong.
|
||||
|
||||
The rule shape is documented in `services/schedule/rule.py` and repeated to the
|
||||
model in the `tool.schedule` fragment, which is deliberately worded from
|
||||
`task.schedule_compile` -- the prompt that has been turning people's words into
|
||||
this same JSON since the feature shipped.
|
||||
|
||||
## What the tool answers with
|
||||
|
||||
`rule.describe(...)`, always, not "done". A schedule is invisible until it fires,
|
||||
which may be days away, so the one moment anybody can check that Monday was
|
||||
understood as Monday is the sentence in the reply. The model is told to quote it.
|
||||
`ORIGIN_MODEL` goes on the row for the same reason: the Scheduled list can then
|
||||
say which of these nobody typed.
|
||||
|
||||
## What it deliberately cannot do
|
||||
|
||||
Touch anybody else's schedules -- `schedules.get` takes the user and returns
|
||||
None for a row that is not theirs, which is the whole authorisation here as it
|
||||
is in the routes. And it cannot create an agent task: `schedules.create` refuses
|
||||
that on its own terms, for reasons written down there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lembas.db.models import ORIGIN_MODEL, TARGET_CHAT, TARGETS, Schedule, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from lembas.services.tools import ToolContext, ToolDef, ToolOutcome
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What a schedule may be told to do, as the model sees it. The same three the
|
||||
# form offers, in the same order, with the wording that says which to pick --
|
||||
# `target` is the field a model gets wrong most often, because all three are
|
||||
# plausible readings of "tell me".
|
||||
TARGET_HELP = (
|
||||
"Where the result goes. "
|
||||
'"report" for something to read later — an investigation, a summary, a '
|
||||
"digest — which is filed in Reports and cannot be replied to. "
|
||||
'"messages" for a short note to the reader in their ongoing conversation, '
|
||||
"which is where a reminder or a fact of the day belongs. "
|
||||
'"chat" to leave it in the task\'s own chat, which is right when the runs '
|
||||
"build on each other and you want the transcript."
|
||||
)
|
||||
|
||||
RULE_HELP = (
|
||||
"When it runs, as an object. Keys, all optional:\n"
|
||||
'- "start": ISO timestamp for the first (or only) run. Required for a '
|
||||
'one-off, and for "every".\n'
|
||||
'- "every": a plain timer — one of {"minutes": n}, {"hours": n}, '
|
||||
'{"days": n}, {"weeks": n}. Use this for "in ten minutes" and "every six '
|
||||
'hours". Minimum one minute.\n'
|
||||
'- "at": a calendar — {"weekdays": [...], "days": [1-31], '
|
||||
'"months": [1-12], "times": ["HH:MM"]}. Leave a list out to mean every one '
|
||||
'of them. Use this for "every Monday at noon" and "daily at nine". '
|
||||
"Weekdays are Monday=0, Tuesday=1, Wednesday=2, Thursday=3, Friday=4, "
|
||||
"Saturday=5, Sunday=6 — count them off rather than guessing, because "
|
||||
"naming the wrong day still looks like a working schedule.\n"
|
||||
'- "count": how many times in total, if a number was given.\n'
|
||||
'- "until": ISO timestamp to stop after, if one was given.\n'
|
||||
'Give both "every" and "at" only for something like "every other Tuesday". '
|
||||
"Times are in the reader's own timezone, which the system prompt states."
|
||||
)
|
||||
|
||||
|
||||
def _outcome(text: str, event: dict[str, Any]) -> ToolOutcome:
|
||||
"""Imported here rather than at module scope: `services/tools.py` imports
|
||||
this module to build the definitions, so a top-level import back is a cycle.
|
||||
"""
|
||||
from lembas.services.tools import ToolOutcome
|
||||
|
||||
return ToolOutcome(text, event)
|
||||
|
||||
|
||||
def _error(name: str, message: str) -> ToolOutcome:
|
||||
return _outcome(message, {"name": name, "status": "error", "error": message})
|
||||
|
||||
|
||||
def _summary(schedule: Schedule, owner: User | None) -> str:
|
||||
return rule_service.describe(schedule.rule_json or {}, zone=clock.zone_for(owner))
|
||||
|
||||
|
||||
def _row(schedule: Schedule, owner: User | None) -> dict[str, Any]:
|
||||
"""One schedule as the transcript shows it, and as the model reads it back."""
|
||||
return {
|
||||
"id": schedule.id,
|
||||
"title": schedule.title,
|
||||
"summary": _summary(schedule, owner),
|
||||
"target": schedule.target,
|
||||
"enabled": bool(schedule.enabled),
|
||||
"next": schedule.next_fire_at.isoformat() if schedule.next_fire_at else "",
|
||||
}
|
||||
|
||||
|
||||
# --- Making one ----------------------------------------------------------------
|
||||
async def _run_create(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
title = str(args.get("title") or "").strip()
|
||||
instruction = str(args.get("instruction") or "").strip()
|
||||
target = str(args.get("target") or TARGET_CHAT).strip().lower()
|
||||
raw_rule = args.get("schedule") or args.get("rule") or {}
|
||||
|
||||
if not instruction:
|
||||
return _error(
|
||||
"schedule_create",
|
||||
"A schedule needs an instruction: what should be done each time it "
|
||||
"runs, written out in full. It will be read on its own, days later, "
|
||||
"by a model that was not here — so say everything it needs.",
|
||||
)
|
||||
if not isinstance(raw_rule, dict):
|
||||
return _error("schedule_create", "The schedule must be an object saying when to run.")
|
||||
if target not in TARGETS:
|
||||
target = TARGET_CHAT
|
||||
|
||||
with session_scope() as db:
|
||||
owner = db.get(User, context.owner_id)
|
||||
if owner is None: # pragma: no cover - a session outliving its user
|
||||
return _error("schedule_create", "That account no longer exists.")
|
||||
try:
|
||||
schedule = schedules_service.create(
|
||||
db,
|
||||
owner=owner,
|
||||
title=title or instruction[:60],
|
||||
instruction=instruction,
|
||||
rule=raw_rule,
|
||||
target=target,
|
||||
model_id=context.model_id or "",
|
||||
# The value that has been declared and unwritten since the
|
||||
# feature shipped. It is what lets the Scheduled list say which
|
||||
# of these nobody typed.
|
||||
origin=ORIGIN_MODEL,
|
||||
)
|
||||
except schedules_service.ScheduleError as exc:
|
||||
# Its message is written for a person and reads correctly to a
|
||||
# model too -- it says what is wrong with the timing rather than
|
||||
# that something failed, which is what makes the next attempt
|
||||
# different from this one.
|
||||
return _error("schedule_create", str(exc))
|
||||
|
||||
summary = _summary(schedule, owner)
|
||||
log.info("%s scheduled %r by model (%s)", owner.email, schedule.title, summary)
|
||||
return _outcome(
|
||||
# The summary first, and phrased so quoting it is the obvious thing
|
||||
# to do: it is the only chance anybody has to notice that Monday was
|
||||
# read as Monday before the first run arrives.
|
||||
f"Scheduled {schedule.title!r}: {summary}. "
|
||||
f"The result goes to {schedule.target}. Tell the reader this summary "
|
||||
f"in your reply, in your own words, so they can correct it now rather "
|
||||
f"than when it first runs.",
|
||||
{
|
||||
"name": "schedule_create",
|
||||
"query": schedule.title,
|
||||
"status": "ok",
|
||||
"detail": summary,
|
||||
"results": [_row(schedule, owner)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- Reading them back ---------------------------------------------------------
|
||||
async def _run_list(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
with session_scope() as db:
|
||||
owner = db.get(User, context.owner_id)
|
||||
# `visible` is the same narrowing the routes use, and is the whole
|
||||
# authorisation: it answers with nothing at all for a missing user.
|
||||
rows = list(db.scalars(schedules_service.visible(owner).order_by(Schedule.created_at)))
|
||||
if not rows:
|
||||
return _outcome(
|
||||
"There are no schedules.",
|
||||
{"name": "schedule_list", "status": "ok", "results": []},
|
||||
)
|
||||
listed = [_row(row, owner) for row in rows]
|
||||
lines = "\n".join(
|
||||
f"- {row['id']}: {row['title']} — {row['summary']}, to {row['target']}"
|
||||
+ ("" if row["enabled"] else " (paused)")
|
||||
for row in listed
|
||||
)
|
||||
return _outcome(
|
||||
f"{len(listed)} schedule(s):\n{lines}",
|
||||
{"name": "schedule_list", "status": "ok", "results": listed},
|
||||
)
|
||||
|
||||
|
||||
# --- Changing and stopping -----------------------------------------------------
|
||||
async def _run_update(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
with session_scope() as db:
|
||||
owner = db.get(User, context.owner_id)
|
||||
schedule = schedules_service.get(db, str(args.get("id") or ""), owner)
|
||||
if schedule is None:
|
||||
return _error(
|
||||
"schedule_update",
|
||||
"There is no such schedule, or it belongs to someone else. "
|
||||
"schedule_list gives the ids you can use.",
|
||||
)
|
||||
|
||||
raw_rule = args.get("schedule") or args.get("rule")
|
||||
enabled = args.get("enabled")
|
||||
try:
|
||||
if raw_rule is not None or args.get("title") or args.get("instruction") is not None:
|
||||
schedules_service.update(
|
||||
db,
|
||||
schedule,
|
||||
owner=owner,
|
||||
title=args.get("title"),
|
||||
instruction=args.get("instruction"),
|
||||
rule=raw_rule if isinstance(raw_rule, dict) else None,
|
||||
target=str(args.get("target") or "") or None,
|
||||
)
|
||||
if enabled is not None:
|
||||
schedules_service.set_enabled(db, schedule, owner=owner, enabled=bool(enabled))
|
||||
except schedules_service.ScheduleError as exc:
|
||||
return _error("schedule_update", str(exc))
|
||||
|
||||
summary = _summary(schedule, owner)
|
||||
state = "running" if schedule.enabled else "paused"
|
||||
return _outcome(
|
||||
f"Updated {schedule.title!r}: {summary}, {state}. Say the new timing "
|
||||
f"in your reply.",
|
||||
{
|
||||
"name": "schedule_update",
|
||||
"query": schedule.title,
|
||||
"status": "ok",
|
||||
"detail": summary,
|
||||
"results": [_row(schedule, owner)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _run_cancel(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
with session_scope() as db:
|
||||
owner = db.get(User, context.owner_id)
|
||||
schedule = schedules_service.get(db, str(args.get("id") or ""), owner)
|
||||
if schedule is None:
|
||||
return _error(
|
||||
"schedule_cancel",
|
||||
"There is no such schedule, or it belongs to someone else. "
|
||||
"schedule_list gives the ids you can use.",
|
||||
)
|
||||
title = schedule.title
|
||||
# The transcript is kept, which is `delete`'s own default and the right
|
||||
# one: removing a timer must not delete a conversation as a side effect.
|
||||
schedules_service.delete(db, schedule, keep_chat=True)
|
||||
log.info("%s cancelled schedule %r by model", owner.email if owner else "?", title)
|
||||
return _outcome(
|
||||
f"Stopped {title!r}. It will not run again; what it has already "
|
||||
f"written is kept.",
|
||||
{"name": "schedule_cancel", "query": title, "status": "ok", "results": []},
|
||||
)
|
||||
|
||||
|
||||
def tool_defs() -> list[ToolDef]:
|
||||
"""The four, built here so `services/tools.py` need not know the wording."""
|
||||
from lembas.services.tools import FAMILY_SCHEDULE, RISK_READ, RISK_WRITE, ToolDef
|
||||
|
||||
return [
|
||||
ToolDef(
|
||||
name="schedule_create",
|
||||
family=FAMILY_SCHEDULE,
|
||||
description=(
|
||||
"Set something up to happen later, or repeatedly, without anybody "
|
||||
"asking again. Use this whenever the reader says when something "
|
||||
"should happen — “in ten minutes”, “every Monday at noon”, "
|
||||
"“daily”, “remind me”. Writing a note or a memory instead does "
|
||||
"not make anything happen at the time; a note is read only when "
|
||||
"somebody goes looking for it. Say the resulting timing back to "
|
||||
"the reader so they can correct it before the first run."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "A short name for this, five words or fewer.",
|
||||
},
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"What to do each time it runs, written out in full and "
|
||||
"as an instruction rather than a description. It is "
|
||||
"read on its own, with none of this conversation "
|
||||
"around it and nobody to ask, so say everything it "
|
||||
"needs."
|
||||
),
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"enum": list(TARGETS),
|
||||
"description": TARGET_HELP,
|
||||
},
|
||||
"schedule": {"type": "object", "description": RULE_HELP},
|
||||
},
|
||||
"required": ["instruction", "schedule"],
|
||||
},
|
||||
run=_run_create,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="schedule_list",
|
||||
family=FAMILY_SCHEDULE,
|
||||
description=(
|
||||
"Everything already scheduled, with its id, its timing in words "
|
||||
"and when it next runs. Worth calling before setting something "
|
||||
"up, so an existing one is changed rather than duplicated, and "
|
||||
"before answering a question about what is scheduled."
|
||||
),
|
||||
parameters={"type": "object", "properties": {}},
|
||||
run=_run_list,
|
||||
risk=RISK_READ,
|
||||
),
|
||||
ToolDef(
|
||||
name="schedule_update",
|
||||
family=FAMILY_SCHEDULE,
|
||||
description=(
|
||||
"Change a schedule: its timing, its instruction, its title, where "
|
||||
"its result goes, or whether it is paused. Omit a field to leave "
|
||||
"it alone. Changing the timing restarts the count, because an "
|
||||
"edited schedule is a new intention. Ids come from schedule_list."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "From schedule_list."},
|
||||
"title": {"type": "string"},
|
||||
"instruction": {"type": "string"},
|
||||
"target": {"type": "string", "enum": list(TARGETS)},
|
||||
"schedule": {"type": "object", "description": RULE_HELP},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"False to pause it, true to resume. Resuming counts "
|
||||
"from now, so a schedule paused for a month does not "
|
||||
"come back owing runs."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
run=_run_update,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="schedule_cancel",
|
||||
family=FAMILY_SCHEDULE,
|
||||
description=(
|
||||
"Stop a schedule for good and remove it. What it has already "
|
||||
"written is kept. To stop one temporarily, use schedule_update "
|
||||
"with enabled false instead."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "From schedule_list."}},
|
||||
"required": ["id"],
|
||||
},
|
||||
run=_run_cancel,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["RULE_HELP", "TARGET_HELP", "tool_defs"]
|
||||
@@ -67,6 +67,11 @@ LABELS: dict[str, str] = {
|
||||
"report_search": "Reports searched",
|
||||
"report_get": "Report read",
|
||||
"image_generate": "Image",
|
||||
# Work set up to happen later.
|
||||
"schedule_create": "Scheduled",
|
||||
"schedule_list": "Schedules read",
|
||||
"schedule_update": "Schedule changed",
|
||||
"schedule_cancel": "Schedule stopped",
|
||||
"memory_add": "Memory saved",
|
||||
"memory_forget": "Memory removed",
|
||||
"skill_get": "Skill read",
|
||||
@@ -103,6 +108,10 @@ ICONS: dict[str, str] = {
|
||||
"report_search": "search",
|
||||
"report_get": "file-text",
|
||||
"image_generate": "image",
|
||||
"schedule_create": "clock",
|
||||
"schedule_list": "clock",
|
||||
"schedule_update": "clock",
|
||||
"schedule_cancel": "stop-circle",
|
||||
"memory_add": "star",
|
||||
"memory_forget": "trash",
|
||||
"skill_get": "sparkle",
|
||||
@@ -144,6 +153,9 @@ ACTIONS: dict[str, str] = {
|
||||
"report_search": "Search reports",
|
||||
"report_get": "Read a report",
|
||||
"image_generate": "Generate an image",
|
||||
"schedule_create": "Set up a schedule",
|
||||
"schedule_update": "Change a schedule",
|
||||
"schedule_cancel": "Stop a schedule",
|
||||
"memory_add": "Remember something",
|
||||
"memory_forget": "Forget something",
|
||||
"skill_get": "Read a skill",
|
||||
@@ -176,6 +188,11 @@ DETAIL_KEYS: dict[str, str] = {
|
||||
# it. Also what makes the box on the card editable: a prompt corrected
|
||||
# before it runs is the commonest useful edit this feature will see.
|
||||
"image_generate": "prompt",
|
||||
# The instruction, not the timing. An approval card has room for one line,
|
||||
# and the instruction is the part a person can read and correct -- the
|
||||
# timing is an object, and the tool answers with it in words afterwards,
|
||||
# which is where it is actually checkable.
|
||||
"schedule_create": "instruction",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{% if item.row.target == "report" %}<span class="badge">files a report</span>{% endif %}
|
||||
{# Which of these nobody typed. A schedule is invisible until it fires, so
|
||||
the list is where a model's decision is checkable at all -- and the
|
||||
reader needs to be able to tell one apart from their own without
|
||||
opening it. #}
|
||||
{% if item.row.origin == "model" %}<span class="badge">set up for you</span>{% endif %}
|
||||
{% if not item.row.enabled %}<span class="badge badge--warning">paused</span>{% endif %}
|
||||
<a class="btn btn--sm" href="/scheduled/{{ item.row.id }}/edit">Edit</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user