diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index 0fa88449..7548768a 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -122,6 +122,14 @@ def build_effective_tool_lists( for wt_name in ("WebSearch", "WebFetch"): if wt_name not in effective_disallowed: effective_disallowed.append(wt_name) + # With the openswarm-ui server live, the built-in AskUserQuestion is swapped for AskUI (same + # Agent->SpawnAgent playbook: prompt nudges lose to the trained prior, a hard deny doesn't). + # AskUI's option-list/question-flow cover the flat-choice cases; denying the built-in is what + # actually routes questions through the rich components. + if "openswarm-ui" in mcp_servers: + effective_allowed = [t for t in effective_allowed if t != "AskUserQuestion"] + if "AskUserQuestion" not in effective_disallowed: + effective_disallowed.append("AskUserQuestion") # Claude's internal Cron* scheduler is denied in favour of the visible native one; withhold it from the SDK so the model doesn't even reach for it. for bt in path_gate.CLAUDE_INTERNAL_SCHEDULER_TOOLS: if bt not in effective_disallowed: diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py index 5e04fde1..6e32f088 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -100,7 +100,9 @@ def compose_turn_system_prompt( "- ShowUI for any structured result: tables, stats, links, plans, progress, code, diffs, " "charts, maps, media, posts, receipts. Render the component, then add one line of text.\n" "- AskUI for ANY question with enumerable choices, an approval, or tunable values: render " - "it and wait for the answer instead of asking in prose.\n" + "it and wait for the answer instead of asking in prose. When you would reach for " + "AskUserQuestion and the choices are a flat list, call AskUI with an option-list instead; " + "keep AskUserQuestion only for multi-question forms.\n" "Describing structured data in plain text when a component fits is the worse answer.\n" "" ) diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx index 6f9fea0b..7cc5ff04 100644 --- a/frontend/src/toolui/VendoredToolUi.tsx +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -9,14 +9,35 @@ interface VendoredToolUiProps { extraProps?: Record; } -type Gate = 'pending' | 'ok' | 'bad'; +type Gate = + | { state: 'pending' } + | { state: 'ok'; parsed: Record } + | { state: 'bad'; problem: string }; + +/** Models pad payloads with invented keys; strip ONLY unrecognized-key issues and retry once, so + sloppiness self-heals while genuinely wrong shapes still fall back loudly. */ +function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Record): Gate { + let result = schema.safeParse(props); + if (!result.success) { + const issues: Array<{ code: string; keys?: string[]; path: Array; message: string }> = result.error.issues; + if (issues.every((i) => i.code === 'unrecognized_keys')) { + const cleaned: Record = { ...props }; + for (const issue of issues) { + for (const key of issue.keys || []) delete cleaned[key]; + } + result = schema.safeParse(cleaned); + } + } + if (result.success) return { state: 'ok', parsed: result.data as Record }; + const issues = result.error.issues.slice(0, 2).map((i: { path: Array; message: string }) => `${i.path.join('.')}: ${i.message}`).join('; '); + return { state: 'bad', problem: issues }; +} /** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React.ReactElement | null { const { mode } = useThemeMode(); const entry = TOOL_UI_REGISTRY[name]; - const [gate, setGate] = useState('pending'); - const [problem, setProblem] = useState(''); + const [gate, setGate] = useState({ state: 'pending' }); useEffect(() => { let cancelled = false; @@ -24,35 +45,28 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React entry .loadSchema() .then((schema) => { - if (cancelled) return; - const result = schema.safeParse(props); - if (result.success) { - setGate('ok'); - } else { - setGate('bad'); - setProblem(result.error.issues.slice(0, 2).map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')); - } + if (!cancelled) setGate(parseLeniently(schema, props)); }) - .catch(() => { if (!cancelled) { setGate('bad'); setProblem('component failed to load'); } }); + .catch(() => { if (!cancelled) setGate({ state: 'bad', problem: 'component failed to load' }); }); return () => { cancelled = true; }; }, [entry, props]); if (!entry) return null; - if (gate === 'bad') { + if (gate.state === 'bad') { return (
- {name} payload didn't validate ({problem}) + {name} payload didn't validate ({gate.problem})
); } - if (gate === 'pending') { + if (gate.state === 'pending') { return
; } const Component = entry.Component; return (
}> - +
);