[eric] agents: AskUserQuestion swaps to AskUI when openswarm-ui is live (nudges lost to the trained prior; live-proven haiku turn) + lenient unknown-key strip before strict zod fallback

This commit is contained in:
ciregenz
2026-07-19 23:52:31 -07:00
parent 0779692a54
commit b5b8c5cb4a
3 changed files with 41 additions and 17 deletions
@@ -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:
@@ -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"
"</rich_ui>"
)
+30 -16
View File
@@ -9,14 +9,35 @@ interface VendoredToolUiProps {
extraProps?: Record<string, unknown>;
}
type Gate = 'pending' | 'ok' | 'bad';
type Gate =
| { state: 'pending' }
| { state: 'ok'; parsed: Record<string, unknown> }
| { 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<string, unknown>): Gate {
let result = schema.safeParse(props);
if (!result.success) {
const issues: Array<{ code: string; keys?: string[]; path: Array<string | number>; message: string }> = result.error.issues;
if (issues.every((i) => i.code === 'unrecognized_keys')) {
const cleaned: Record<string, unknown> = { ...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<string, unknown> };
const issues = result.error.issues.slice(0, 2).map((i: { path: Array<string | number>; 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<Gate>('pending');
const [problem, setProblem] = useState<string>('');
const [gate, setGate] = useState<Gate>({ 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 (
<div style={{ fontSize: '0.75rem', opacity: 0.55, padding: '4px 0' }}>
{name} payload didn't validate ({problem})
{name} payload didn't validate ({gate.problem})
</div>
);
}
if (gate === 'pending') {
if (gate.state === 'pending') {
return <div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />;
}
const Component = entry.Component;
return (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
<Component {...props} {...(extraProps || {})} />
<Component {...gate.parsed} {...(extraProps || {})} />
</Suspense>
</div>
);