Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/docs/fast-sessions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,21 @@ list or cancel wakeups at any time; archiving a Session cancels all of its
wakeups.

Wakeups belong to the conversation and do not require an administrator. A
Session may hold up to ten active wakeups, intervals range from one minute to
Session may hold up to ten active wakeups, intervals range from one second to
seven days, and recurring wakeups can be bounded by a run count or an end time.
Intervals under five minutes must carry one of those bounds. A recurring wakeup
whose turns fail five times in a row is retired. Deployment-wide recurring work
that should run outside a conversation or report to a channel is an
[automation](/automations) instead.

Relative and interval schedules use `in <positive integer>s|m|h|d` and
`every <positive integer>s|m|h|d`: seconds, minutes, hours, or days. Use `in 30s`,
not fractional `in 0.5m`. For a short recurring check, use `every 30s x3` or an
`until` end time; recurring intervals under five minutes still require a bound.
Cron schedules remain five-field expressions, such as `cron 0 9 * * 1-5`.
Delivery is best effort: `in 30s` does not guarantee a reply exactly 30 seconds
later.

## Session and execution access

Any signed-in deployment user with a Session link can view its timeline and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ ${reactionGuidance}
- Use "cancel_task" only when the user explicitly asks to stop an active task.
- Call a deployment MCP tool when it can answer the request. Fast receives the same actor-authorized remote and deployment-proxied MCP tool catalog as delegated tasks; local stdio servers remain sandbox-only. Servers listed with a tool prefix expose each tool individually with its native JSON schema. On-demand servers are reached through \`find_integration_tools\` (fetch the schema by server id and tool name, or search by keywords) followed by \`call_integration_tool\`; the same acknowledgement, duplicate, and audit rules apply to both paths.
- Use \`roomote_manage_custom_automations\` for custom automation lifecycle requests. It uses the current user's deployment authorization, is admin-only, and is unavailable to advisor and judge subagents. List before modifying an existing automation, use "list_models" before setting a model override, use update with "enabled" to enable or disable, and use "run_now" rather than "launch_task" to test an automation. Communicate first on a human-authored turn; platform events remain exempt. Delete only when the user explicitly requests it, and after creating an automation ask whether they want to run it now.
- Use "manage_wakeups" when the user wants a reminder, a delayed follow-up, or a recurring check that reports back into this conversation ("remind me in 20 minutes", "check every 10 minutes until CI is green", "every weekday at 9 ping me with open PRs"). The schedule is one short string: "in 20m" for a reminder, "every 10m" or "every 1m x3" for a repeating check, "cron 0 9 * * 1-5" for a calendar schedule. Send only the fields the action needs. It is scoped to this conversation and available to every participant. Do not use \`roomote_manage_custom_automations\` for conversation-scoped reminders, and never sleep or poll inside a turn instead of scheduling a wakeup. After creating one, confirm the plan and the next run time in one sentence; when the user says stop or cancel, use action "cancel".
- Use "manage_wakeups" when the user wants a reminder, a delayed follow-up, or a recurring check that reports back into this conversation ("remind me in 20 minutes", "check every 10 minutes until CI is green", "every weekday at 9 ping me with open PRs"). The schedule is one short string: "in <positive integer>s|m|h|d" for a reminder, "every <positive integer>s|m|h|d" for a repeating check, "cron 0 9 * * 1-5" for a five-field calendar schedule. Prefer "in 30s", not fractional "in 0.5m". Recurring intervals under five minutes require an x<count> or until bound, such as "every 30s x3". Delivery is best effort; never promise an exact 30-second reply. Send only the fields the action needs. It is scoped to this conversation and available to every participant. Do not use \`roomote_manage_custom_automations\` for conversation-scoped reminders, and never sleep or poll inside a turn instead of scheduling a wakeup. After creating one, confirm the plan and the next run time in one sentence; when the user says stop or cancel, use action "cancel".

${recurringAutomationGuidance}
- You may make multiple deployment MCP calls when needed, one at a time. Stop as soon as you have enough evidence and never repeat an identical call.
Expand Down
81 changes: 80 additions & 1 deletion packages/cloud-agents/src/server/session-wakeups/parse.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 23 additions & 18 deletions packages/cloud-agents/src/server/session-wakeups/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,36 @@ export type ParsedSessionWakeupSchedule = {
until: Date | null;
};

const UNIT_MINUTES: Record<string, number> = {
m: 1,
min: 1,
mins: 1,
minute: 1,
minutes: 1,
h: 60,
hr: 60,
hrs: 60,
hour: 60,
hours: 60,
d: 24 * 60,
day: 24 * 60,
days: 24 * 60,
const UNIT_SECONDS: Record<string, number> = {
s: 1,
sec: 1,
secs: 1,
second: 1,
seconds: 1,
m: 60,
min: 60,
mins: 60,
minute: 60,
minutes: 60,
h: 3600,
hr: 3600,
hrs: 3600,
hour: 3600,
hours: 3600,
d: 24 * 3600,
day: 24 * 3600,
days: 24 * 3600,
};

// Every pattern below runs on text whose whitespace has already been
// collapsed to single spaces and whose length is capped by the contract, so
// the patterns use literal single spaces and stay linear.
const DURATION = String.raw`(\d+) ?(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)`;
const DURATION = String.raw`(\d+) ?(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)`;
const DURATION_RE = new RegExp(`^${DURATION}$`, 'i');
const IN_RE = new RegExp(`^(?:once )?in ${DURATION}$`, 'i');
const AT_RE = /^(?:once )?at (\S+)$/i;
const EVERY_RE = new RegExp(
`^every (?:${DURATION}|(minute|hour|day))(?: (.*))?$`,
`^every (?:${DURATION}|(second|minute|hour|day))(?: (.*))?$`,
'i',
);
const CRON_RE = /^cron ((?:\S+ ){4}\S+)(?: (.*))?$/i;
Expand All @@ -59,7 +64,7 @@ function invalid(text: string, detail?: string): SessionWakeupValidationError {

function durationMinutes(amount: string, unit: string): number {
const minutes =
Number.parseInt(amount, 10) * UNIT_MINUTES[unit.toLowerCase()]!;
(Number.parseInt(amount, 10) * UNIT_SECONDS[unit.toLowerCase()]!) / 60;
if (!Number.isFinite(minutes) || minutes <= 0) {
throw new SessionWakeupValidationError('A duration must be positive.');
}
Expand Down Expand Up @@ -147,7 +152,7 @@ export function parseSessionWakeupSchedule(
const everyMatch = EVERY_RE.exec(text);
if (everyMatch) {
const everyMinutes = everyMatch[3]
? UNIT_MINUTES[everyMatch[3].toLowerCase()]!
? UNIT_SECONDS[everyMatch[3].toLowerCase()]! / 60
: durationMinutes(everyMatch[1]!, everyMatch[2]!);
const normalized = normalizeSessionWakeupSchedule(
{ mode: 'interval', everyMinutes },
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 26 additions & 11 deletions packages/cloud-agents/src/server/session-wakeups/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { CronExpressionParser } from 'cron-parser';

import {
SESSION_WAKEUP_MAX_INTERVAL_MINUTES,
SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES,
SESSION_WAKEUP_MIN_INTERVAL_MINUTES,
SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES,
sessionWakeupScheduleSchema,
type SessionWakeupSchedule,
} from '@roomote/types';

Expand Down Expand Up @@ -88,8 +87,20 @@ export function normalizeSessionWakeupSchedule(
'A once schedule needs inMinutes or at.',
);
}
if (
hasDelay &&
!sessionWakeupScheduleSchema.safeParse({
mode: 'once',
at: now.toISOString(),
inMinutes: input.inMinutes,
}).success
) {
throw new SessionWakeupValidationError(
'A once delay must be a positive whole number of seconds, at most 30 days out.',
);
}
const at = hasDelay
? new Date(now.getTime() + input.inMinutes! * MINUTE_MS)
? new Date(now.getTime() + Math.round(input.inMinutes! * MINUTE_MS))
: parseIsoDate(input.at!, 'at');
if (at.getTime() <= now.getTime()) {
throw new SessionWakeupValidationError(
Expand All @@ -114,18 +125,16 @@ export function normalizeSessionWakeupSchedule(
};
}
case 'interval': {
if (
!Number.isInteger(input.everyMinutes) ||
input.everyMinutes < SESSION_WAKEUP_MIN_INTERVAL_MINUTES ||
input.everyMinutes > SESSION_WAKEUP_MAX_INTERVAL_MINUTES
) {
if (!sessionWakeupScheduleSchema.safeParse(input).success) {
throw new SessionWakeupValidationError(
`everyMinutes must be a whole number between ${SESSION_WAKEUP_MIN_INTERVAL_MINUTES} and ${SESSION_WAKEUP_MAX_INTERVAL_MINUTES}.`,
'An interval must be a positive whole number of seconds, at most 7 days.',
);
}
return {
schedule: { mode: 'interval', everyMinutes: input.everyMinutes },
firstRunAt: new Date(now.getTime() + input.everyMinutes * MINUTE_MS),
firstRunAt: new Date(
now.getTime() + Math.round(input.everyMinutes * MINUTE_MS),
),
};
}
case 'cron': {
Expand Down Expand Up @@ -168,7 +177,9 @@ export function computeNextSessionWakeupRunAt(
return at.getTime() > from.getTime() ? at : null;
}
case 'interval':
return new Date(from.getTime() + schedule.everyMinutes * MINUTE_MS);
return new Date(
from.getTime() + Math.round(schedule.everyMinutes * MINUTE_MS),
);
case 'cron':
return nextCronOccurrence(schedule.expression, schedule.timezone, from);
}
Expand Down Expand Up @@ -274,6 +285,10 @@ function estimateCronMinGapMinutes(
}

function formatMinutes(minutes: number): string {
if (!Number.isInteger(minutes)) {
const seconds = Math.round(minutes * 60);
return `${seconds} second${seconds === 1 ? '' : 's'}`;
}
if (minutes % (24 * 60) === 0) {
const days = minutes / (24 * 60);
return `${days} day${days === 1 ? '' : 's'}`;
Expand Down
Loading
Loading