Merge remote-tracking branch 'origin/aidan/feat/scheduled-tasks' into aidan/feat/scheduled-tasks-agent-tools

# Conflicts:
#	backend/apps/agents/agent_manager.py
This commit is contained in:
abccodes
2026-06-19 03:42:59 -07:00
6 changed files with 68 additions and 59 deletions
+19 -19
View File
@@ -29,13 +29,13 @@ from backend.config.paths import SESSIONS_DIR
from backend.apps.agents.core.error_classify import (
_NON_TRANSIENT_PATTERNS,
_TRANSIENT_CAPACITY_PATTERNS,
_is_auth_error,
_is_free_trial_exhausted,
_is_long_context_error,
_is_out_of_tokens,
_is_transient_capacity_error,
_is_unknown_model_error,
_extract_reset_hint,
p_is_auth_error,
p_is_free_trial_exhausted,
p_is_long_context_error,
p_is_out_of_tokens,
p_is_transient_capacity_error,
p_is_unknown_model_error,
p_extract_reset_hint,
)
from backend.apps.agents.manager.session.session_store import (
_delete_session_file,
@@ -606,7 +606,7 @@ class AgentManager:
# Native-scheduler tools that commit or mutate a recurring schedule.
# Always-on MCP servers fall through to the always_allow default, so
# these would otherwise fire silently; force them through ApprovalBar.
_SCHEDULE_GATED = {
p_SCHEDULE_GATED = {
"mcp__openswarm-schedule__ScheduleWorkflow",
"mcp__openswarm-schedule__UpdateScheduledWorkflow",
"mcp__openswarm-schedule__DeleteScheduledWorkflow",
@@ -744,7 +744,7 @@ class AgentManager:
# twin of the crontab gate above: real, user-visible, hard-to-undo,
# so it goes through ApprovalBar every time regardless of the
# always_allow default that always-on MCP servers fall through to.
if tool_name in _SCHEDULE_GATED:
if tool_name in p_SCHEDULE_GATED:
return "ask", None
if tool_name == "Bash" and isinstance(tool_input, dict):
bash_match = _match_bash_catastrophic_pattern(str(tool_input.get("command") or ""))
@@ -866,7 +866,7 @@ class AgentManager:
# Record which tools each step touched (in-memory; the executor/test
# path persists step_usage once at run end). Captures every tool the
# gate sees so a step's tool set is complete, not only the ones that
# prompted. No-op outside a workflow run or before a step is set.
# prompted.
mem = p_approval_memory.get(session_id)
if mem is None or mem.current_step_id is None:
return
@@ -3087,7 +3087,7 @@ class AgentManager:
_ticker_task = None
stderr_snapshot = "\n".join(_stderr_buffer[-50:])
if (
_is_transient_capacity_error(e, extra_text=stderr_snapshot)
p_is_transient_capacity_error(e, extra_text=stderr_snapshot)
and capacity_retry_attempt < len(_CAPACITY_BACKOFFS)
):
wait = _CAPACITY_BACKOFFS[capacity_retry_attempt]
@@ -3177,7 +3177,7 @@ class AgentManager:
# subsequent step (title gen, follow-up tool turn, etc.).
# Don't blast a "context exceeded" card over a completed reply.
_streamed_substantive = bool(stream_text_msg_id) and _current_turn_emitted
if _streamed_substantive and _is_long_context_error(e, extra_text=_stderr_tail):
if _streamed_substantive and p_is_long_context_error(e, extra_text=_stderr_tail):
# Mark the session completed (not error), keep the assistant
# reply visible, and skip the overflow card. The next user
# turn will properly hit the pre-send guard if the chat is
@@ -3192,7 +3192,7 @@ class AgentManager:
except Exception:
pass
return
if _is_long_context_error(e, extra_text=_stderr_tail):
if p_is_long_context_error(e, extra_text=_stderr_tail):
friendly_msg = (
"This conversation has grown too large for your account's "
"standard context window. Long-context requests require an "
@@ -3236,7 +3236,7 @@ class AgentManager:
})
except Exception:
logger.debug("submit_diagnostic for context_overflow failed", exc_info=True)
elif _is_free_trial_exhausted(e, extra_text=_stderr_tail):
elif p_is_free_trial_exhausted(e, extra_text=_stderr_tail):
# Free runs spent. Flip back to own_key and show a friendly
# "connect a model" upsell instead of a raw 402.
try:
@@ -3259,12 +3259,12 @@ class AgentManager:
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
elif _is_out_of_tokens(e, extra_text=_stderr_tail):
elif p_is_out_of_tokens(e, extra_text=_stderr_tail):
# Usage/quota spent (plan cap, API credit balance, provider quota).
# Not a bug and not transient: tell the user plainly it's a token
# limit and let them wait for the reset or switch models. Drives the
# blocking out_of_tokens card (same slot as auth/context_overflow).
_reset = _extract_reset_hint(f"{e!s}\n{_stderr_tail}")
_reset = p_extract_reset_hint(f"{e!s}\n{_stderr_tail}")
friendly_msg = (
f"You're out of tokens on {session.model or 'this model'}. "
"This isn't a bug, and nothing's wrong on your end. Your usage "
@@ -3284,7 +3284,7 @@ class AgentManager:
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
elif _is_auth_error(e, extra_text=_stderr_tail):
elif p_is_auth_error(e, extra_text=_stderr_tail):
# Three sub-cases the user can hit, with distinct fixes:
# 1. "No credentials for provider: claude", user picked a
# -cc route but doesn't have Claude Pro/Max connected
@@ -3350,7 +3350,7 @@ class AgentManager:
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
elif _is_unknown_model_error(e, extra_text=_stderr_tail):
elif p_is_unknown_model_error(e, extra_text=_stderr_tail):
# Upstream rejected the model code itself (e.g. Codex 1211 on a
# ChatGPT plan that lacks our GPT ids). Track it; the friendly
# "add an API key / pick another model" card is rendered frontend-side.
@@ -3372,7 +3372,7 @@ class AgentManager:
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
elif _is_transient_capacity_error(e, extra_text=_stderr_tail):
elif p_is_transient_capacity_error(e, extra_text=_stderr_tail):
friendly_msg = (
"provider_rate_limit: This model hit your account or "
"session rate limit. Wait until the reset time shown by "
+8 -19
View File
@@ -37,7 +37,7 @@ _NON_TRANSIENT_PATTERNS = re.compile(
)
def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
def p_is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when the upstream error is the 'long context tier required' 429.
Used by the catch-all error path to emit a friendly context-overflow
@@ -54,7 +54,7 @@ def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
))
def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
def p_is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
"""True when the cloud says the machine's free runs are spent (a 402 with
type free_trial_exhausted). The catch-all path uses this to flip back to
own_key and show a friendly connect-a-model upsell instead of a raw error.
@@ -69,13 +69,7 @@ def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
))
def _is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool:
"""True when the turn was rejected because the user's usage/quota is spent: a
plan cap, API credit balance, or provider quota. This is a 'wait for the reset
window or switch models' situation, NOT a transient rate-limit blip (handled by
_is_transient_capacity_error) and NOT an auth/connection failure. Drives the
friendly 'just a token issue' card.
"""
def p_is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool:
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
@@ -93,11 +87,9 @@ def _is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool:
))
def _extract_reset_hint(text: str) -> str:
def p_extract_reset_hint(text: str) -> str:
"""Pull a human reset phrase ('at 7:42 AM', 'in 2h 30m', 'after 1m 59s') out of
a provider usage error so we can tell the user when their limit comes back.
Keeps the leading preposition so it slots into 'It resets <hint>.'. Empty when
the provider didn't say.
"""
if not text:
return ""
@@ -109,13 +101,10 @@ def _extract_reset_hint(text: str) -> str:
return m.group(1).strip() if m else ""
def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
def p_is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when the upstream error is a 401/403 auth failure.
Used by the catch-all error path to surface a friendly "subscription
expired / reconnect" card instead of dumping the raw 401 JSON. The most
common cause: the OpenSwarm Pro bearer or 9Router OAuth token has expired
while the UI still shows the connection as 'connected'.
expired / reconnect" card instead of dumping the raw 401 JSON.
"""
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
@@ -133,7 +122,7 @@ def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
))
def _is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
def p_is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when the upstream rejects the model code itself (e.g. a ChatGPT/Codex
subscription whose plan doesn't expose the GPT model id we send: code 1211
'Unknown Model, please check the model code'). The fix isn't retry, it's a
@@ -153,7 +142,7 @@ def _is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
))
def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
def p_is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
# The Claude CLI's underlying ProcessError stringifies to a generic
# "Command failed with exit code 1 / Check stderr output for details";
# the real cause (rate_limit_error / No pool capacity available / 429
+3 -3
View File
@@ -24,7 +24,7 @@ PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
def _local_timezone_name() -> str:
def p_local_timezone_name() -> str:
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
if not name:
try:
@@ -311,8 +311,8 @@ def _call(method: str, path: str, body=None) -> dict:
def _build_schedule_from_preset(preset: str, args: dict) -> dict:
local_tz = _local_timezone_name()
base = {"timezone": args.get("timezone") or local_tz, "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
local_tz = p_local_timezone_name()
base = {"timezone": args.get("timezone") or local_tz, "ends_at": None, "max_runs": None, "runs_count": 0}
if preset == "custom":
return {
**base,
+7 -7
View File
@@ -5,8 +5,8 @@ import backend # noqa: F401 (path sanity asserted below)
from backend.apps.settings.models import AppSettings
from backend.apps.settings.credentials import proxy_auth
from backend.apps.agents.core.error_classify import (
_is_free_trial_exhausted,
_is_transient_capacity_error,
p_is_free_trial_exhausted,
p_is_transient_capacity_error,
)
from backend.apps.agents.providers.registry import resolve_model_id_for_sdk
from backend.apps.subscription.free_trial import _has_own_model
@@ -41,11 +41,11 @@ def test_free_trial_resolves_to_a_bare_anthropic_id():
def test_exhaustion_is_classified_and_not_retried():
assert _is_free_trial_exhausted(Exception("error type free_trial_exhausted"))
assert _is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs"))
assert not _is_free_trial_exhausted(Exception("overloaded, try again"))
assert p_is_free_trial_exhausted(Exception("error type free_trial_exhausted"))
assert p_is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs"))
assert not p_is_free_trial_exhausted(Exception("overloaded, try again"))
# Must NOT look transient, or the agent loop would retry a spent trial forever.
assert not _is_transient_capacity_error(Exception("free_trial_exhausted"))
assert not p_is_transient_capacity_error(Exception("free_trial_exhausted"))
def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits():
@@ -53,7 +53,7 @@ def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits():
'{"subtype":"api_retry","data":{"error_status":429,'
'"error":"rate_limit","max_retries":10}}'
)
assert _is_transient_capacity_error(
assert p_is_transient_capacity_error(
Exception("Command failed with exit code 1"),
extra_text=system_event_tail,
)
+6 -6
View File
@@ -592,8 +592,8 @@ def test_router_auth_pattern_does_not_falsely_match_normal_text():
def test_is_auth_error_classifier():
"""The classifier at agent_manager.py:_is_auth_error covers many shapes."""
from backend.apps.agents.agent_manager import _is_auth_error
"""The classifier at agent_manager.py:p_is_auth_error covers many shapes."""
from backend.apps.agents.agent_manager import p_is_auth_error
# Real shapes that must be caught
matches = [
@@ -606,7 +606,7 @@ def test_is_auth_error_classifier():
Exception("Provider not configured: gemini"),
]
for e in matches:
assert _is_auth_error(e), f"should match: {e}"
assert p_is_auth_error(e), f"should match: {e}"
# Non-auth errors must not match
non_matches = [
@@ -616,15 +616,15 @@ def test_is_auth_error_classifier():
Exception("File not found"),
]
for e in non_matches:
assert not _is_auth_error(e), f"should NOT match: {e}"
assert not p_is_auth_error(e), f"should NOT match: {e}"
def test_is_auth_error_with_stderr_tail():
"""The classifier also reads stderr buffer text."""
from backend.apps.agents.agent_manager import _is_auth_error
from backend.apps.agents.agent_manager import p_is_auth_error
e = Exception("Command failed with exit code 1")
stderr = "...\n[codex/gpt-5.5] [401]: Provided authentication token is expired"
assert _is_auth_error(e, extra_text=stderr)
assert p_is_auth_error(e, extra_text=stderr)
# ===========================================================================
@@ -35,6 +35,13 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
const dispatch = useAppDispatch();
const workflows = useAppSelector((s) => Object.values(s.workflows.items));
const allPaused = useAppSelector((s) => s.workflows.paused);
// Live clock for the "now" line; a snapshot would drift and refDate may be
// a navigated week, so it can't double as the current moment.
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id);
}, []);
// Right-click menu: pinned position + the workflow whose pill was
// clicked. Same anchor pattern as MUI's menu examples.
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
@@ -163,6 +170,8 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
const start = startOfWeek(today);
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
const HOURS = HOURS_24;
const nowColIdx = days.findIndex((d) => sameDay(d, now));
const nowTopPx = (now.getHours() + now.getMinutes() / 60) * SLOT_H;
// Prefer the short zone name ("PDT", "EST", "JST") so the label
// reads in plain English instead of "GMT-7". formatToParts is wide-
// supported; if it ever fails we degrade silently rather than show
@@ -194,7 +203,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
);
})}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}`, position: 'relative' }}>
{HOURS.map((hour, hourIdx) => (
<React.Fragment key={hour}>
{/* Hour label sits inside its row (top-aligned) rather than
@@ -247,6 +256,17 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
})}
</React.Fragment>
))}
{nowColIdx >= 0 && (
<Box sx={{
position: 'absolute', pointerEvents: 'none', zIndex: 3,
top: `${nowTopPx}px`,
left: `calc(64px + ${nowColIdx} * ((100% - 64px) / 7))`,
width: 'calc((100% - 64px) / 7)',
height: 0, borderTop: `2px solid ${c.status.error}`,
}}>
<Box sx={{ position: 'absolute', left: -3, top: -4, width: 7, height: 7, borderRadius: '50%', bgcolor: c.status.error }} />
</Box>
)}
</Box>
{ctxMenuEl}
</Box>
@@ -264,7 +284,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
reads cleanly in both light and dark themes. */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, py: 0.6 }}>
{WEEKDAY_LABEL_SHORT.map((l, i) => (
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.secondary, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
@@ -357,7 +377,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
'&:hover .ev-title': { color: accent },
}}>
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: 1, flexShrink: 0 }} />
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
@@ -412,7 +432,7 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext
bgcolor: accent + '14',
color: c.text.primary,
borderLeft: `3px solid ${accent}`,
borderRadius: '4px',
borderRadius: c.radius.sm,
px: 0.65, py: 0,
fontSize: eventFontSize, fontWeight: 500,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
@@ -435,7 +455,7 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext
minWidth: 20, px: 0.4,
bgcolor: accent + '22',
color: accent,
borderRadius: '4px',
borderRadius: c.radius.sm,
fontSize: eventFontSize, fontWeight: 700,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
'&:hover': { bgcolor: accent + '33' },