mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] ui-tools: full jsonschema validation server-side (constraint classes like step>=1 slipped the hand walker); dead turns freeze in-progress spinners to pending in chat and pill; model told to end trackers truthfully
This commit is contained in:
@@ -85,7 +85,9 @@ TOOLS = [
|
||||
"The component renders in place of raw text; still give a one-line text summary after. "
|
||||
"LIVE UPDATES: calling ShowUI again with the SAME component and props.id updates that "
|
||||
"card in place. Use this to advance progress-tracker/plan step statuses AS you complete "
|
||||
"each step of real work, or to refresh data; never mint a new id for an update."
|
||||
"each step of real work, or to refresh data; never mint a new id for an update. Before "
|
||||
"ending your turn, send a final same-id update with truthful terminal statuses; never "
|
||||
"leave a step marked in-progress for work you are not actually doing."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
@@ -133,10 +135,14 @@ def validate(component: str, props: dict) -> str:
|
||||
return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}"
|
||||
# Vendored components: validate against the GENERATED JSON Schema so a bad payload comes back
|
||||
# as a teaching error the model can fix in-turn, instead of a dead render it never hears about.
|
||||
# jsonschema gives full-constraint parity with the client zod gate (minimum/minLength/minItems
|
||||
# slipped through the hand walker: question-flow step>=1 rendered server-side, died client-side).
|
||||
entry = GENERATED.get(component)
|
||||
if entry and isinstance(entry.get("schema"), dict):
|
||||
errors = []
|
||||
p_check(props, entry["schema"], "props", errors)
|
||||
errors = p_full_validate(props, entry["schema"])
|
||||
if errors is None:
|
||||
errors = []
|
||||
p_check(props, entry["schema"], "props", errors)
|
||||
if errors:
|
||||
return (
|
||||
f"{component} payload invalid: " + "; ".join(errors[:4])
|
||||
@@ -145,6 +151,25 @@ def validate(component: str, props: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def p_full_validate(props: dict, schema: dict):
|
||||
"""Full JSON Schema validation via jsonschema; None = library unavailable (fallback walker runs)."""
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
validator = jsonschema.Draft202012Validator(schema)
|
||||
out = []
|
||||
for err in sorted(validator.iter_errors(props), key=lambda e: len(e.path)):
|
||||
where = "props" + "".join(f".{p}" if isinstance(p, str) else f"[{p}]" for p in err.path)
|
||||
out.append(f"{where}: {err.message[:90]}")
|
||||
if len(out) >= 6:
|
||||
break
|
||||
return out
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def p_type_ok(value, t: str) -> bool:
|
||||
if t == "string":
|
||||
return isinstance(value, str)
|
||||
|
||||
@@ -1714,7 +1714,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
if (isShowUiPair(item)) {
|
||||
return (
|
||||
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
|
||||
<ToolUiBubble pair={item} sessionId={session.id} isPending={isPending} suppressReveal={item.call.id === justStreamedId} />
|
||||
<ToolUiBubble pair={item} sessionId={session.id} isPending={isPending} suppressReveal={item.call.id === justStreamedId} sessionRunning={sessionRunning} />
|
||||
{compactionChip}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ToolCallBubble from '../tool-bubbles/ToolCallBubble';
|
||||
import type { ToolPair } from '../tool-bubbles/ToolCallBubble';
|
||||
import { parseShowUiPayload } from './showUiPayload';
|
||||
import { parseShowUiPayload, freezeIfDone } from './showUiPayload';
|
||||
import ShowUiWidgetView from './ShowUiWidgetView';
|
||||
|
||||
interface ToolUiBubbleProps {
|
||||
@@ -10,11 +10,16 @@ interface ToolUiBubbleProps {
|
||||
sessionId: string;
|
||||
isPending: boolean;
|
||||
suppressReveal: boolean;
|
||||
sessionRunning?: boolean;
|
||||
}
|
||||
|
||||
/** Renders a ShowUI call as its inline component; any schema mismatch falls back to the plain tool bubble. */
|
||||
function ToolUiBubble({ pair, sessionId, isPending, suppressReveal }: ToolUiBubbleProps): React.ReactElement {
|
||||
const payload = parseShowUiPayload(pair);
|
||||
function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunning = false }: ToolUiBubbleProps): React.ReactElement {
|
||||
const rawPayload = parseShowUiPayload(pair);
|
||||
const payload = useMemo(
|
||||
() => (rawPayload ? freezeIfDone(rawPayload, sessionRunning) : null),
|
||||
[rawPayload, sessionRunning],
|
||||
);
|
||||
if (!payload) {
|
||||
return (
|
||||
<ToolCallBubble call={pair.call} result={pair.result} isPending={isPending} sessionId={sessionId} suppressReveal={suppressReveal} />
|
||||
|
||||
@@ -181,3 +181,19 @@ function parseShowUiInput(input: unknown): ShowUiPayload | null {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// A dead turn must not keep spinners alive: once the agent stops, any step still marked
|
||||
// in-progress is work that is NOT happening, so it renders as its truthful stalled state.
|
||||
export function freezeIfDone(payload: ShowUiPayload, running: boolean): ShowUiPayload {
|
||||
if (running || payload.component !== 'vendored') return payload;
|
||||
if (payload.name !== 'progress-tracker' && payload.name !== 'plan') return payload;
|
||||
const steps = payload.props.steps ?? payload.props.todos;
|
||||
if (!Array.isArray(steps)) return payload;
|
||||
const liveKey = payload.name === 'plan' ? 'in_progress' : 'in-progress';
|
||||
if (!steps.some((s) => (s as { status?: string })?.status === liveKey)) return payload;
|
||||
const frozen = steps.map((s) =>
|
||||
(s as { status?: string })?.status === liveKey ? { ...(s as object), status: 'pending' } : s,
|
||||
);
|
||||
const key = payload.name === 'plan' ? 'todos' : 'steps';
|
||||
return { ...payload, props: { ...payload.props, [key]: frozen } };
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import WindowControls from './WindowControls';
|
||||
import { useTiledStyle } from './tileZones';
|
||||
import AgentNarratorPill from '../desktop/AgentNarratorPill';
|
||||
import { extractLatestTodos } from '../desktop/agentTodos';
|
||||
import { extractLatestShowUi } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
|
||||
import { extractLatestShowUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar';
|
||||
@@ -694,7 +694,10 @@ const AgentCard: React.FC<Props> = ({
|
||||
// Desktop-shell narrator pill: a collapsed card with nothing to ask renders as the minimal pill
|
||||
// (live turn label + plan checklist); approvals and drafts keep the full card so their UI has a home.
|
||||
const todos = useMemo(() => extractLatestTodos(session.messages || []), [session.messages]);
|
||||
const pillArtifact = useMemo(() => extractLatestShowUi(session.messages || []), [session.messages]);
|
||||
const pillArtifact = useMemo(() => {
|
||||
const artifact = extractLatestShowUi(session.messages || []);
|
||||
return artifact ? freezeIfDone(artifact, session.status === 'running') : null;
|
||||
}, [session.messages, session.status]);
|
||||
const pillMode = !expanded && !hasPending && !isDraft && !tileZone;
|
||||
const pillLabel = session.turn_label?.label || displayChatTitle(session);
|
||||
const pillRunning = session.status === 'running';
|
||||
|
||||
Reference in New Issue
Block a user