mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 04:07:44 +02:00
[eric] workflows: LLM step labels + real active_step_idx telemetry + Pause toggles schedule
This commit is contained in:
@@ -209,12 +209,26 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
# session is idle before posting the next step. Keeps the runner
|
||||
# safe regardless of how long each turn takes.
|
||||
step_error: Optional[str] = None
|
||||
for step in steps:
|
||||
for idx, step in enumerate(steps):
|
||||
# Broadcast the step bump before sending so RunningView flips
|
||||
# the disc immediately, not after the agent finishes the step.
|
||||
run.active_step_idx = idx
|
||||
run.last_tool_label = None
|
||||
try:
|
||||
from backend.apps.agents.ws_manager import ws_manager as _wsm
|
||||
await _wsm.broadcast_global("workflow:run", {
|
||||
"workflow_id": wf.id,
|
||||
"run": run.model_dump(mode="json"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await agent_manager.send_message(session.id, step)
|
||||
await _await_session_idle(session.id)
|
||||
sess_state = agent_manager.sessions.get(session.id)
|
||||
if sess_state is not None and getattr(sess_state, "status", None) == "error":
|
||||
step_error = "Agent session entered error state"
|
||||
# Pin active step so FailedView can render the X on the
|
||||
# right row. error_step_idx == active_step_idx at fail time.
|
||||
break
|
||||
|
||||
run.finished_at = datetime.now()
|
||||
|
||||
@@ -120,6 +120,10 @@ class WorkflowRun(BaseModel):
|
||||
# the workflow is running. Surfaced under the active step in RunningView
|
||||
# (Image #40) so the user can tell the run is still making progress.
|
||||
last_tool_label: Optional[str] = None
|
||||
# Currently-executing step index (0-based). Executor bumps this each
|
||||
# time it dispatches a step prompt and broadcasts the run. RunningView
|
||||
# uses this for the disc statuses; estimate fallback only when null.
|
||||
active_step_idx: Optional[int] = None
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
|
||||
@@ -138,16 +138,21 @@ async def create_workflow(body: WorkflowCreate):
|
||||
wf.icon = _derive_icon(wf)
|
||||
if wf.schedule.enabled:
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf)
|
||||
# Force-generate title + description from the steps in a single aux
|
||||
# call. Previously we only filled missing description, leaving stale
|
||||
# session names ("Inbox check") as titles. One round-trip, both
|
||||
# fields, overwrites whatever shallow draft the FE sent.
|
||||
# Force-generate title + description + per-step labels from the steps
|
||||
# in a single aux call. Previously we only filled missing description,
|
||||
# leaving stale session names ("Inbox check") as titles. Step labels
|
||||
# are the 3-6 word at-a-glance headlines surfaced in StepList; without
|
||||
# them the UI falls back to truncated raw prompts.
|
||||
try:
|
||||
title, description = await _generate_title_and_description(wf)
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
if title:
|
||||
wf.title = title
|
||||
if description:
|
||||
wf.description = description
|
||||
if labels and len(labels) == len(wf.steps):
|
||||
for i, lab in enumerate(labels):
|
||||
if lab:
|
||||
wf.steps[i].label = lab
|
||||
except Exception:
|
||||
pass
|
||||
storage.save_workflow(wf)
|
||||
@@ -155,35 +160,35 @@ async def create_workflow(body: WorkflowCreate):
|
||||
return _enriched(wf)
|
||||
|
||||
|
||||
async def _generate_title_and_description(wf: Workflow) -> tuple[str, str]:
|
||||
"""Single aux-model call returning (title, description).
|
||||
async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]:
|
||||
"""Single aux-model call returning (title, description, step_labels).
|
||||
|
||||
Uses strict JSON output so both fields come back in one round-trip.
|
||||
Returns ("", "") on any failure so the caller can write back
|
||||
unconditionally without dropping the workflow create.
|
||||
One round-trip for all three so we don't burn 3x aux cost. Returns
|
||||
("", "", []) on any failure; caller writes back unconditionally.
|
||||
"""
|
||||
if not wf.steps:
|
||||
return "", ""
|
||||
return "", "", []
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.agents.providers.registry import get_anthropic_client_for_model
|
||||
from backend.apps.settings.settings import load_settings as _ls
|
||||
except Exception:
|
||||
return "", ""
|
||||
return "", "", []
|
||||
settings = _ls()
|
||||
try:
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
except Exception:
|
||||
return "", ""
|
||||
return "", "", []
|
||||
steps_lines = "\n".join(f"{i+1}. {s.text}" for i, s in enumerate(wf.steps) if s.text)
|
||||
n_steps = len(wf.steps)
|
||||
prompt = (
|
||||
"You name and describe a saved automation routine that the user "
|
||||
"can re-run later. The routine is defined ONLY by the numbered "
|
||||
"steps below; treat those as the user's instructions to the "
|
||||
"agent.\n\n"
|
||||
"can re-run later, AND produce a short at-a-glance label for "
|
||||
"each step. The routine is defined ONLY by the numbered steps "
|
||||
"below; treat those as the user's instructions to the agent.\n\n"
|
||||
"Return STRICT JSON, nothing else, no code fence:\n"
|
||||
" {\"title\": string, \"description\": string}\n\n"
|
||||
' {"title": string, "description": string, "step_labels": [string, ...]}\n\n'
|
||||
"title rules:\n"
|
||||
"- 2 to 5 words, Title Case\n"
|
||||
"- Starts with a verb-noun pair when possible (e.g. \"Summarize "
|
||||
@@ -197,27 +202,26 @@ async def _generate_title_and_description(wf: Workflow) -> tuple[str, str]:
|
||||
"digest each Sunday at 9am.\"\n"
|
||||
" \"Pulls today's calendar plus inbox, writes a Notion brief, "
|
||||
"and texts you the link.\"\n"
|
||||
"- Examples of BAD output you MUST AVOID verbatim:\n"
|
||||
" \"This is an AI-generated description...\"\n"
|
||||
" \"Auto-generated description used to wrap workflows...\"\n"
|
||||
" Any sentence that talks about the description itself\n"
|
||||
"- Start with a verb. Do NOT start with \"This\", \"A\", \"An\", "
|
||||
"\"The workflow\", \"This routine\".\n\n"
|
||||
f"step_labels rules:\n"
|
||||
f"- EXACTLY {n_steps} entries, one per step, same order.\n"
|
||||
"- Each label: 3 to 6 words, Sentence case.\n"
|
||||
"- Imperative verb-led (\"Summarize emails & calendar\", \"Make "
|
||||
"brief in notion\", \"Email brief link to me\").\n"
|
||||
"- No trailing punctuation, no quotes, no emoji.\n"
|
||||
"- Should read as the human-friendly NAME of the step, NOT a "
|
||||
"restatement of the prompt.\n\n"
|
||||
f"Steps:\n{steps_lines}"
|
||||
)
|
||||
import json
|
||||
import re as _re
|
||||
|
||||
def _extract_json_object(s: str) -> Optional[dict]:
|
||||
"""Find the first {...} block and json.loads it. Handles code
|
||||
fences, prose preambles, and trailing chatter that some aux
|
||||
models like to add."""
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
s = _re.sub(r"^```(?:json)?\s*", "", s, flags=_re.IGNORECASE)
|
||||
s = _re.sub(r"\s*```\s*$", "", s)
|
||||
# Greedy brace match; falls through to direct json.loads if no
|
||||
# braces are visible at all.
|
||||
start = s.find("{")
|
||||
end = s.rfind("}")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
@@ -228,13 +232,9 @@ async def _generate_title_and_description(wf: Workflow) -> tuple[str, str]:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Prefill the assistant turn with `{` so the model is steered into
|
||||
# emitting JSON from the first token. The Anthropic API treats a
|
||||
# trailing assistant message as a prefill; we'll glue it back on
|
||||
# before parsing.
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
max_tokens=240,
|
||||
max_tokens=400 + n_steps * 30,
|
||||
messages=[
|
||||
{"role": "user", "content": prompt},
|
||||
{"role": "assistant", "content": "{"},
|
||||
@@ -248,16 +248,16 @@ async def _generate_title_and_description(wf: Workflow) -> tuple[str, str]:
|
||||
raw = "{" + text.strip() if not text.strip().startswith("{") else text.strip()
|
||||
data = _extract_json_object(raw)
|
||||
if not data:
|
||||
logger.warning("description gen: failed to parse aux model output: %s", raw[:400])
|
||||
return "", ""
|
||||
logger.warning("workflow meta gen: failed to parse aux model output: %s", raw[:400])
|
||||
return "", "", []
|
||||
title = (data.get("title") or "").strip()[:80]
|
||||
description = (data.get("description") or "").strip()[:500]
|
||||
if not description:
|
||||
logger.warning("description gen: empty description from aux model. Raw: %s", raw[:400])
|
||||
return title, description
|
||||
raw_labels = data.get("step_labels") or []
|
||||
labels = [str(x or "").strip()[:60] for x in raw_labels] if isinstance(raw_labels, list) else []
|
||||
return title, description, labels
|
||||
except Exception as e:
|
||||
logger.warning("description gen: aux model call failed: %s", e)
|
||||
return "", ""
|
||||
logger.warning("workflow meta gen: aux model call failed: %s", e)
|
||||
return "", "", []
|
||||
|
||||
|
||||
def _last_run_cost(wid: str) -> float:
|
||||
|
||||
@@ -697,12 +697,21 @@ function RunningHeader({ workflowId }: { workflowId: string }) {
|
||||
} catch { /* best-effort */ }
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'saved', runId: null } }));
|
||||
}, [dispatch, workflowId, run]);
|
||||
const onPause = React.useCallback(() => {
|
||||
// Pause is "this run keeps going but no further fires queue." We
|
||||
// can't actually pause a streaming agent mid-call, so this just
|
||||
// flips the schedule paused flag for now.
|
||||
void workflow;
|
||||
}, [workflow]);
|
||||
// Pause = "let this run finish, but stop firing future schedules."
|
||||
// Can't actually pause a streaming agent turn mid-call, so we flip
|
||||
// schedule.enabled so the scheduler stops queuing the next fire. The
|
||||
// button label flips to "Resume" while paused; user can re-enable
|
||||
// without leaving the running view.
|
||||
const isPaused = !!workflow && !workflow.schedule.enabled && workflow.schedule.runs_count > 0;
|
||||
const onPauseToggle = React.useCallback(async () => {
|
||||
if (!workflow) return;
|
||||
const next = { ...workflow.schedule, enabled: isPaused };
|
||||
await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: next as Workflow['schedule'] },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
}, [dispatch, workflow, isPaused]);
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
@@ -713,19 +722,21 @@ function RunningHeader({ workflowId }: { workflowId: string }) {
|
||||
<StopRounded sx={{ fontSize: 15 }} />
|
||||
Stop
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onPause}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
<PauseRounded sx={{ fontSize: 15 }} />
|
||||
Pause
|
||||
</Box>
|
||||
<Tooltip title={isPaused ? 'Schedule is paused. Click to resume future fires.' : 'Pause future scheduled fires. This run finishes normally.'}>
|
||||
<Box
|
||||
onClick={onPauseToggle}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
<PauseRounded sx={{ fontSize: 15 }} />
|
||||
{isPaused ? 'Resume' : 'Pause'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,10 +141,11 @@ export function RunningView({ workflow, steps, runs, mode = 'card' }: {
|
||||
const runId = card?.runId || null;
|
||||
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
|
||||
|
||||
// Synthesize an active step index off the run's elapsed/expected ratio
|
||||
// until we wire per-step backend telemetry. Mirrors the heuristic the
|
||||
// old StepList used; placeholder until last_tool_call drives this.
|
||||
const activeIdx = useActiveStepIdx(steps.length, runs, runId);
|
||||
// Prefer the backend's real active_step_idx (broadcast on each step
|
||||
// bump in executor.execute). Fall back to elapsed/expected heuristic
|
||||
// when the field is missing (older runs or first-frame race).
|
||||
const heuristicIdx = useActiveStepIdx(steps.length, runs, runId);
|
||||
const activeIdx = typeof run?.active_step_idx === 'number' ? run.active_step_idx : heuristicIdx;
|
||||
const statuses: StepStatus[] = steps.map((_, i) =>
|
||||
i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending',
|
||||
);
|
||||
@@ -464,13 +465,19 @@ export function FailedView({ workflow, steps, runs, mode = 'card' }: {
|
||||
}
|
||||
|
||||
function guessFailedIdx(run: WorkflowRun | null, total: number): number {
|
||||
if (!run || !run.error) return Math.max(0, total - 1);
|
||||
// Backend may serialize as "Step N: ..."; pull N when present so the
|
||||
// X lands on the right row instead of always the last.
|
||||
const m = /step\s+(\d+)/i.exec(run.error);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= total) return n - 1;
|
||||
if (!run) return Math.max(0, total - 1);
|
||||
// Backend pins active_step_idx at the failed step before flipping
|
||||
// status to 'failure'. Prefer that; fall back to parsing "Step N"
|
||||
// out of the error string for legacy runs.
|
||||
if (typeof run.active_step_idx === 'number') {
|
||||
return Math.max(0, Math.min(total - 1, run.active_step_idx));
|
||||
}
|
||||
if (run.error) {
|
||||
const m = /step\s+(\d+)/i.exec(run.error);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= total) return n - 1;
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(total - 1, 1));
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ export interface WorkflowRun {
|
||||
triggered_by: 'schedule' | 'manual' | 'retry';
|
||||
/** Live "what's the agent doing" subtitle while status is 'running'. */
|
||||
last_tool_label?: string | null;
|
||||
/** Currently-executing 0-based step index while status is 'running';
|
||||
* freezes on the failed step when status flips to 'failure'. */
|
||||
active_step_idx?: number | null;
|
||||
}
|
||||
|
||||
/** Transient view-only state per card; position lives in dashboardLayoutSlice.workflowCards. */
|
||||
|
||||
Reference in New Issue
Block a user