feat(ai): make script-gen the only subagent output protocol

Retire the flat-JSONL retry rung. Every subagent rung now emits a JS
program (script-gen); reduced_complexity and minimal_skills only
narrow the loaded skill set — they no longer switch the output format
to positional _parent JSONL, whose omittable parent field collapsed a
whole tree into flat siblings when a model skipped it. script-gen's
I(parent, node) makes parenting a positional argument that cannot be
dropped, and the reasoning-harvest fix made it robust across models.
parse_nodes stays for the modify/chat paths that still consume flat
node JSON; the jsonl-format generation skills are removed.
This commit is contained in:
Fini 2026-07-04 17:31:22 +08:00
parent eaa979d01c
commit 2e21d94ff6
9 changed files with 218 additions and 370 deletions

View file

@ -467,11 +467,11 @@ By category:
memory / 2 s wall-clock / 4096 recorded-line / 256 KiB source-size
limits) whose only effect is calling `I(parent, node)` to emit the
same program. The orchestrator's subagents share this exact runner:
script-gen (a real JS program driving `I(...)`) is THE default
generation protocol on every model's full first attempt; the
reduced-complexity / minimal-skills retry rungs fall back to a flat
newline-delimited JSON protocol instead (one `{"_parent": ...}`
node object per line, no script sandbox), parsed by `parse_nodes`.
script-gen (a real JS program driving `I(...)`) is THE generation
protocol on every subagent rung. The reduced-complexity /
minimal-skills retry rungs only narrow the loaded skill set; they do
not switch output protocol. `parse_nodes` remains available for the
separate modify/chat paths that still consume flat node JSON.
Read tools snapshot `Document` state at registration time. Write tools stay `&self`: they validate args and return `ToolOutcome::OkWithCommand(result, command)` for the host to apply via `Document::apply_mcp_command(command)`. The apply path follows pre-validate-then-mutate discipline (id space, target existence, geometry, hex, container-children consent) so a bad arg never leaves the document half-mutated.

View file

@ -56,30 +56,13 @@ const btnA = I(row, {"type":"ref","ref":"shadcn-btn-primary","descendants":{"sha
const btnB = I(row, {"type":"ref","ref":"shadcn-btn-secondary","descendants":{"shadcn-btn-secondary-label":{"content":"Cancel"}}});
```
## Fallback dialect — flat `_parent` JSONL (retry rungs only)
script-gen is not always active: the reduced-complexity / minimal-skills retry
rungs fall back to a flat, newline-delimited JSON protocol instead (one
`{"_parent": ...}` node object per line, no script sandbox). If THAT is your
active output protocol for this attempt, spell the ref the JSONL way: a ref is
ONE line like any other node — give it an `id`, set its `_parent` to the
container it belongs in, and `ref:"<componentId>"`. It needs no `children`;
`descendants` works identically to the script-gen form. Never mix the two
dialects in one output.
```json
{"_parent":"row","id":"btn-a","type":"ref","ref":"shadcn-btn-primary","descendants":{"shadcn-btn-primary-label":{"content":"Save"}}}
{"_parent":"row","id":"btn-b","type":"ref","ref":"shadcn-btn-secondary","descendants":{"shadcn-btn-secondary-label":{"content":"Cancel"}}}
```
## Rules
- The `ref` value MUST be a component id from the AVAILABLE COMPONENTS list.
Never invent an id; if nothing matches, build the element normally.
- Match the dialect to your ACTIVE output protocol: script-gen's
`I(parent, {"type":"ref",...})` by default, the flat `{"_parent":...,
"type":"ref",...}` JSONL line only on a reduced-complexity/minimal-skills
retry rung. Never mix them in the same output.
- Use script-gen's `I(parent, {"type":"ref",...})` form on every subagent
generation attempt. Reduced-complexity and minimal-skills retries narrow the
skill set only; they do not switch output protocol.
- A `ref` node does NOT take its own visual props (fill / cornerRadius /
padding) — those live on the master. Customize only via `descendants`.
- Keys in `descendants` MUST be ids that exist inside the chosen master.

View file

@ -1,52 +0,0 @@
---
name: jsonl-format-simplified
description: Simplified flat _parent JSONL format for basic tier models
phase: [generation]
trigger:
flags: [isBasicTier]
priority: 0
budget: 1700
category: base
---
CRITICAL — OUTPUT FORMAT: Emit raw JSONL only (the EXAMPLE block at the bottom of this skill). Never use tool-call wrappers, `[TOOL_CALL]`, or `{tool => ...}` syntax, and never mix prose with the structured output.
The TYPES / RULES / DESIGN SYSTEM TOKENS sections below describe the underlying PenNode schema — the shape of each node in the JSONL output.
TYPES:
frame (width,height,layout,gap,padding,justifyContent,alignItems,cornerRadius,fill), rectangle (width,height,cornerRadius,fill), text (content,fontFamily,fontSize,fontWeight,fill,width,textAlign), icon_font (iconFontName,width,height,fill), text_input (placeholder,value,leadingIcon,trailingIcon,width,height,fill,cornerRadius) — use for form fields, NOT a role=input frame
SHARED: id, type, name, \_parent
RULES:
- Root: type="frame", width="fill_container", height="fit_content", layout="vertical", \_parent=null.
- Every node carries "\_parent" — null for the root, else its parent's id. No x/y on layout children.
- width/height: number | "fill_container" | "fit_content".
- fill: [{"type":"solid","color":"#hex" | "$color-*"}].
- Text: never set height. Use width="fill_container" for wrapping text.
- Icons: use icon_font with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings). Sizes: 16/20/24px.
- Buttons: frame with padding=[12,24] containing a text child.
- Section header actions: use an icon-only `icon_font` with `iconFontName:"chevron-right"` or `"arrow-right"`; do NOT write visible "See all", "View all", "查看全部", or "查看更多" text in compact mobile headers.
- Explicit user tokens win: if the user names exact radius/spacing values (for example "cornerRadius 8px" / "圆角 8px" / "间距 12px"), use those exact numbers for ordinary component `cornerRadius` and `gap` even when examples or style guides show larger values.
- No emoji characters. No markdown. No explanation outside the structured output.
DESIGN SYSTEM TOKENS — prefer refs over literals; the renderer resolves them against the user's seeded palette (or a default light palette when un-seeded), so refs are SAFE even on a fresh document.
- COLORS: `$color-{bg-deep|surface|surface-2|surface-3|border|border-strong|text-primary|text-body|text-muted|text-subtle|accent|destructive|success|scrim|info-bg|info-text|success-bg|success-text|warning-bg|warning-text|danger-bg|danger-text|chart-1..6}`. Light defaults: bg-deep `#F8FAFC`, surface `#FFFFFF`, text-primary `#0F172A`, text-body `#334155`, text-muted `#64748B`, accent `#2563EB`, border `#E2E8F0`.
- BRAND-ACCENT CONSISTENCY (critical): use ONE accent color across the whole screen. Whatever color the brand uses (logo, prices, active nav item, primary CTA), use that SAME color for active states and decisive actions. Default search-adjacent filter controls are neutral surfaces with an accent icon; use an accent-filled filter only when it is explicitly selected/active. Do NOT paint the brand one color (e.g. an orange food app) while leaving other accents on `$color-accent` (it defaults to blue) — mixing them makes the UI clash. Pick one accent and apply it consistently.
- TYPOGRAPHY: `$type-{display|h1|h2|h3|body|caption}-{size|weight|line-height}`. Defaults: display 64/700/1.0, h1 24/600/1.2, h2 20/600/1.25, h3 16/600/1.3, body 14/400/1.5, caption 12/400/1.4.
- SPACING / RADIUS: `$spacing-{1|2|3|4|5}` = 4/8/12/16/24 px. `$radius-{sm|md|lg}` = 4/8/12 px.
USE refs for standard semantic colors and typography sizes/weights/line-heights that match the scale above. KEEP literal hex / numbers for brand-specific colors not in the palette, off-scale pixel values, and "white text on accent" (`#FFFFFF`).
Output one JSON object per line (NO enclosing [ ] array, NO "children" field). Each line carries "\_parent" — null for the root, else its parent's id (which appears on an earlier line). Express the WHOLE tree via \_parent; a flat list of siblings with no \_parent links renders BROKEN (collapses into a vertical stack).
EXAMPLE:
```json
{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content","layout":"vertical","gap":24,"padding":[48,24],"fill":[{"type":"solid","color":"$color-bg-deep"}]}
{"_parent":"root","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48,"fontWeight":700,"fontFamily":"Space Grotesk","fill":[{"type":"solid","color":"$color-text-primary"}]}
{"_parent":"root","id":"desc","type":"text","name":"Description","content":"AI-powered learning","width":"fill_container","fill":[{"type":"solid","color":"$color-text-muted"}]}
{"_parent":"root","id":"cta","type":"frame","name":"CTA","padding":[14,28],"cornerRadius":10,"justifyContent":"center","fill":[{"type":"solid","color":"$color-accent"}]}
{"_parent":"cta","id":"cta-text","type":"text","content":"Get Started","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}
```

View file

@ -1,62 +0,0 @@
---
name: jsonl-format
description: Sub-agent flat JSONL output format with node types and rules
phase: [generation]
trigger: null
priority: 0
budget: 1700
category: base
---
CRITICAL — OUTPUT FORMAT: Emit raw JSONL only (the FORMAT + ```json example sections at the bottom of this skill). Never use tool-call wrappers, `[TOOL_CALL]`, or `{tool => ...}` syntax, and never mix prose with the structured output.
The TYPES / RULES / DESIGN SYSTEM TOKENS sections below describe the underlying PenNode schema — the shape of each node in the JSONL output.
TYPES:
frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,textAlign,textGrowth,lineHeight,letterSpacing), icon_font (iconFontName,width,height,fill), text_input (placeholder,value,leadingIcon,trailingIcon,width,height,fill,stroke,cornerRadius), text_area (placeholder,value,maxVisibleLines,leadingIcon), number_input (placeholder,value,min,max,step,leadingIcon), path (d,width,height,fill,stroke), image (width,height,imageSearchQuery,imagePrompt). imagePrompt: describe subject+scene+style, NEVER mention background type (transparent/white/plain). Match composition to aspect ratio. **imageSearchQuery MUST be UNIQUE per image — derive it from the surrounding card/dish/section text so each photo on the screen represents a different subject.** Reusing one query (e.g. all images set to "salmon sushi") makes every card render the same photo. **Strongly prefer 2 keywords; never more than 3** — the photo API uses strict AND-search, so 3-keyword queries like "burger combo fries" or "sakura sushi platter" zero-result whereas the 2-keyword forms ("burger fries", "sushi platter") return hundreds.
SHARED: id, type, name, role, x, y, opacity
ROLES: section, row, column, divider | navbar, button, icon-button, badge, input, search-bar | card, stat-card, pricing-card, feature-card | heading, subheading, body-text, caption, label | table, table-row, table-header
width/height: number | "fill_container" | "fit_content". padding: number | [v,h] | [T,R,B,L]. Fill=[{"type":"solid","color":"#hex" | "$color-*"}].
Stroke: {"thickness":N,"fill":[{"type":"solid","color":"#hex" | "$color-*"}]}. Directional: {"thickness":{"bottom":1},"fill":[...]}.
RULES:
- Section root: width="fill_container", height="fit_content", layout="vertical".
- No x/y on children in layout frames. All nodes descend from section root.
- Width consistency: siblings in vertical layout use the SAME width strategy.
- Never "fill_container" inside "fit_content" parent.
- clipContent: true on cards with cornerRadius + image children.
- Text: NEVER set height. Short text (titles, labels, buttons) — omit textGrowth. Long text (>15 chars wrapping) — textGrowth="fixed-width", width="fill_container", lineHeight=1.4-1.6.
- lineHeight: Display 40-56px - 0.9-1.0. Heading 20-36px - 1.0-1.2. Body - 1.4-1.6. letterSpacing: -0.5 to -1 for headlines, 1-3 for uppercase.
- Icons: ALWAYS use icon_font nodes with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings, etc). Sizes: 14/20/24px. NEVER use emoji characters as icon substitutes — they cannot render on canvas.
- Section header actions: in compact mobile headers, use an icon-only `icon_font` (`chevron-right` or `arrow-right`) instead of visible "See all", "View all", "查看全部", or "查看更多" text.
- Explicit user tokens win: if the user names exact radius/spacing values (for example "cornerRadius 8px" / "圆角 8px" / "间距 12px"), use those exact numbers for ordinary component `cornerRadius` and `gap` even when examples or style guides show larger values.
- CJK fonts: "Noto Sans SC"/"Noto Sans JP"/"Noto Sans KR" for headings. CJK lineHeight: 1.3-1.4 headings, 1.6-1.8 body.
- Buttons: frame(padding=[12,24], justifyContent="center") > text. Icon+text: frame(layout="horizontal", gap=8, alignItems="center", padding=[8,16]).
- Card rows: ALL cards width="fill_container" + height="fill_container".
- FORMS: emit input fields as **text_input nodes** (NOT frames with role=input). Put the leading icon on the node via `leadingIcon` (e.g. mail/lock/search), a password reveal via `trailingIcon:"eye"` — do NOT add child icon_font/text nodes for the placeholder. Make inputs interactive by two-way binding: `bindings."bind:value"="$state.<key>"`. ALL inputs AND button use width="fill_container", gap=16-20. (Legacy `role=input`/`search-bar` frames still auto-promote on load, but prefer real text_input nodes.)
- Z-order: Earlier siblings render on top. Overlay elements (badges, indicators, floating buttons) MUST come BEFORE the content they overlap.
DESIGN SYSTEM TOKENS — prefer refs over literals so output respects the user's design system. The renderer resolves refs against `doc.variables` (or a default light palette when un-seeded), so refs are SAFE even when the doc has no design system seeded yet.
- COLORS: `$color-{bg-deep|surface|surface-2|surface-3|border|border-strong|text-primary|text-body|text-muted|text-subtle|accent|destructive|success|scrim|info-bg|info-text|success-bg|success-text|warning-bg|warning-text|danger-bg|danger-text|chart-1..6}`. Light defaults: bg-deep `#F8FAFC`, surface `#FFFFFF`, surface-2 `#F1F5F9`, border `#E2E8F0`, text-primary `#0F172A`, text-body `#334155`, text-muted `#64748B`, text-subtle `#94A3B8`, accent `#2563EB`, destructive `#EF4444`, success `#10B981`.
- SEMANTIC-COLOR DISCIPLINE (critical): the `*-bg` / `*-text` state tokens (`info-bg/info-text`, `success-bg/success-text`, `warning-bg/warning-text`, `danger-bg/danger-text`) are RESERVED for status/feedback elements ONLY — error banners, success toasts, validation messages, status badges. NEVER use them as a decorative or neutral surface. A search input, a category chip, a filter pill, a card, a section background, an icon tile — these are NEUTRAL surfaces: use `$color-surface` / `$color-surface-2` / `$color-surface-3`, or a tint of `$color-accent`, NEVER `$color-danger-bg` (it is a pinkish-red and will clash with the theme). Picking a state token just because it is "a light color" is a bug — match the token's SEMANTICS to the element's purpose.
- BRAND-ACCENT CONSISTENCY (critical): use ONE accent color across the whole screen. Whatever color the brand uses (logo, prices, active nav item, primary CTA), use that SAME color for active states and decisive actions. Default search-adjacent filter controls are neutral surfaces with an accent icon; use an accent-filled filter only when it is explicitly selected/active. Do NOT paint the brand one color (e.g. an orange food app) while leaving other accents on `$color-accent` (it defaults to blue) — mixing them makes the UI clash. Pick one accent and apply it consistently.
- TYPOGRAPHY: `$type-{display|h1|h2|h3|body|caption}-{size|weight|line-height}`. Defaults: display 64/700/1.0, h1 24/600/1.2, h2 20/600/1.25, h3 16/600/1.3, body 14/400/1.5, caption 12/400/1.4. Plus `$type-display-letter-spacing` (-0.5), `$type-uppercase-label-letter-spacing` (1.5).
- SPACING / RADIUS: `$spacing-{1|2|3|4|5}` = 4/8/12/16/24 px. `$radius-{sm|md|lg}` = 4/8/12 px.
USE refs for: standard semantic colors (page bg, surfaces, text levels, borders, accent, alerts, charts), and typography sizes/weights/line-heights that match the scale above. KEEP literal hex / numbers for: brand-specific colors not in the palette (custom logo color, off-palette accent), pixel values that don't match the typography scale, and "white text on accent" (`#FFFFFF`).
FORMAT: \_parent (null=root, else parent-id). Parent before children. Output a single ```json block with ONE node per line.
```json
{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content","layout":"vertical","gap":24,"padding":[48,24],"fill":[{"type":"solid","color":"$color-bg-deep"}]}
{"_parent":"root","id":"header","type":"frame","name":"Header","justifyContent":"space_between","alignItems":"center","width":"fill_container"}
{"_parent":"header","id":"logo","type":"text","name":"Logo","content":"ACME","fontSize":18,"fontWeight":600,"fontFamily":"Space Grotesk","fill":[{"type":"solid","color":"$color-text-primary"}]}
{"_parent":"header","id":"notifBtn","type":"frame","name":"Notification","width":44,"height":44}
{"_parent":"notifBtn","id":"notifIcon","type":"icon_font","name":"Bell","iconFontName":"bell","width":20,"height":20,"fill":"$color-text-primary","x":12,"y":12}
{"_parent":"root","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48,"fontWeight":700,"fontFamily":"Space Grotesk","lineHeight":0.95,"fill":[{"type":"solid","color":"$color-text-primary"}]}
{"_parent":"root","id":"desc","type":"text","name":"Description","content":"AI-powered vocabulary learning that adapts to your pace","fontSize":16,"textGrowth":"fixed-width","width":"fill_container","lineHeight":1.5,"fill":[{"type":"solid","color":"$color-text-muted"}]}
{"_parent":"root","id":"cta","type":"frame","name":"CTA Button","padding":[14,28],"cornerRadius":10,"justifyContent":"center","fill":[{"type":"solid","color":"$color-accent"}]}
{"_parent":"cta","id":"cta-text","type":"text","name":"CTA Label","content":"Get Started","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}
```

View file

@ -116,7 +116,7 @@ mod tests {
fn generation_format_emits_text_input_widgets() {
// Phase 1: form fields must be generated as real `text_input`
// nodes (interactive in preview), not `role=input` mockup frames.
// The jsonl-format skill's node-type list must mention text_input.
// The generation schema's node-type list must mention text_input.
let mentions = get_skills_by_phase(Phase::Generation)
.iter()
.any(|s| s.content.contains("text_input"));

View file

@ -4,9 +4,8 @@
//! (`compactSubAgentSkills` + the `retryAllowed` set).
//!
//! Two filtering modes:
//! - **`minimal_skills`** — keep only `schema` + `jsonl-format` (the
//! bare-minimum kernel for models whose safety scanner times out on
//! a full-size system prompt).
//! - **`minimal_skills`** — keep only `schema` (the bare-minimum skill set for
//! models whose safety scanner times out on a full-size system prompt).
//! - **`reduced_complexity` + `ModelTier::Basic`** — keep the retry
//! `retryAllowed` set (drops `elements` and all non-essential skills
//! so the retry has the smallest viable prompt).
@ -41,7 +40,7 @@ use crate::model_profile::ModelTier;
/// `noStyleGuideMatch` case would inject `design-system`'s conflicting
/// "output ONLY a JSON token object" header alongside `style-defaults`
/// (Codex review 2026-06-06).
/// * `minimal_skills` — When `true`, keep only `schema` + `jsonl-format`.
/// * `minimal_skills` — When `true`, keep only `schema`.
/// * `reduced_complexity` — When `true` AND `tier == Basic`, narrow to the
/// `retryAllowed` set (excludes `elements`).
pub fn apply_skill_filter<T: SkillNamed>(
@ -58,15 +57,11 @@ pub fn apply_skill_filter<T: SkillNamed>(
let before: Vec<String> = skills.iter().map(|s| s.skill_name().to_string()).collect();
let next = if minimal_skills {
// Last-ditch fallback: only the schema + jsonl-format kernel.
// Verbatim port of orchestrator-sub-agent.ts:428-431 — exactly
// two skill names, `jsonl-format-simplified` is NOT kept.
// Last-ditch fallback: only the schema. Prompt assembly appends
// SCRIPT_FORMAT separately, so the output protocol remains script-gen.
skills
.into_iter()
.filter(|s| {
let n = s.skill_name();
n == "schema" || n == "jsonl-format"
})
.filter(|s| s.skill_name() == "schema")
.collect()
} else {
compact_subagent_skills(
@ -88,27 +83,18 @@ pub fn apply_skill_filter<T: SkillNamed>(
};
let kept: std::collections::HashSet<&str> = next.iter().map(|s| s.skill_name()).collect();
let jsonl_simplified_kept = kept.contains("jsonl-format-simplified");
let dropped: Vec<(String, DropReason)> = before
.into_iter()
.filter(|n| !kept.contains(n.as_str()))
.map(|n| {
// verbose jsonl-format dropped while simplified survives = dedup, not tier.
let reason = if n == "jsonl-format" && jsonl_simplified_kept {
DropReason::Deduped
} else {
base_reason
};
(n, reason)
})
.map(|n| (n, base_reason))
.collect();
(next, dropped)
}
/// Port of `compactSubAgentSkills` (orchestrator-sub-agent-compact.ts:4-76):
/// a content-aware base filter for ALL tiers, then a `jsonl-format` dedup,
/// then a Basic-tier allow-set (further narrowed on reduced-complexity).
/// a content-aware base filter for ALL tiers, then a Basic-tier allow-set
/// (further narrowed on reduced-complexity).
fn compact_subagent_skills<T: SkillNamed>(
skills: Vec<T>,
tier: ModelTier,
@ -146,14 +132,12 @@ fn compact_subagent_skills<T: SkillNamed>(
})
.collect();
// When the simplified JSONL format is present, drop the verbose one so a
// Basic-tier model doesn't carry both (orchestrator-sub-agent-compact.ts:24-28).
let has_simplified = next
.iter()
.any(|s| s.skill_name() == "jsonl-format-simplified");
if has_simplified {
next.retain(|s| s.skill_name() != "jsonl-format");
}
// The subagent output protocol is always script-gen; never carry the
// retired flat-JSONL generation skills.
next.retain(|s| {
let name = s.skill_name();
name != "jsonl-format" && name != "jsonl-format-simplified"
});
if tier == ModelTier::Basic {
// Basic-tier allow-set (orchestrator-sub-agent-compact.ts:31-52).
@ -162,8 +146,6 @@ fn compact_subagent_skills<T: SkillNamed>(
// in that case and required when the flag is on.
const ALLOWED: &[&str] = &[
"schema",
"jsonl-format-simplified",
"jsonl-format",
// Manifest output protocol — gated on `hasManifest` at the
// resolve layer; a no-op when the flag is off, required when
// on (same pattern as `elements` below).
@ -205,7 +187,6 @@ fn compact_subagent_skills<T: SkillNamed>(
// `elements` deliberately OMITTED — the retry wants the smallest prompt.
const RETRY_ALLOWED: &[&str] = &[
"schema",
"jsonl-format-simplified",
"layout",
"text-rules",
"mobile-app",
@ -296,7 +277,7 @@ mod tests {
// -----------------------------------------------------------------------
#[test]
fn minimal_skills_keeps_only_schema_and_jsonl_format() {
fn minimal_skills_keeps_only_schema() {
let input = skills(&[
"schema",
"jsonl-format",
@ -306,15 +287,18 @@ mod tests {
"design-md",
]);
let out = filter(input, ModelTier::Full, true, false);
assert_eq!(names(&out), vec!["schema", "jsonl-format"]);
assert_eq!(names(&out), vec!["schema"]);
}
#[test]
fn minimal_skills_drops_jsonl_format_simplified() {
// The TS minimal_skills filter (orchestrator-sub-agent.ts:428-431)
// keeps exactly `schema` + `jsonl-format` — `jsonl-format-simplified`
// is NOT in the allow-set, so it is dropped.
let input = skills(&["schema", "jsonl-format-simplified", "layout", "elements"]);
fn minimal_skills_drops_jsonl_format_skills() {
let input = skills(&[
"schema",
"jsonl-format",
"jsonl-format-simplified",
"layout",
"elements",
]);
let out = filter(input, ModelTier::Basic, true, false);
assert_eq!(names(&out), vec!["schema"]);
}
@ -324,11 +308,11 @@ mod tests {
// minimal_skills takes precedence (early return, reduced ignored).
let input = skills(&["schema", "jsonl-format", "layout", "mobile-app"]);
let out = filter(input, ModelTier::Basic, true, true);
assert_eq!(names(&out), vec!["schema", "jsonl-format"]);
assert_eq!(names(&out), vec!["schema"]);
}
// -----------------------------------------------------------------------
// base filter (all tiers) + jsonl dedup
// base filter (all tiers)
// -----------------------------------------------------------------------
#[test]
@ -430,7 +414,7 @@ mod tests {
}
#[test]
fn jsonl_dedup_drops_verbose_when_simplified_present() {
fn all_script_gen_filter_drops_jsonl_format_skills() {
let input = skills(&[
"schema",
"jsonl-format",
@ -440,9 +424,14 @@ mod tests {
let out = filter(input, ModelTier::Full, false, false);
assert!(
!names(&out).contains(&"jsonl-format"),
"verbose jsonl-format dropped when simplified present"
"verbose jsonl-format must not be mounted in generation prompts"
);
assert!(names(&out).contains(&"jsonl-format-simplified"));
assert!(
!names(&out).contains(&"jsonl-format-simplified"),
"simplified jsonl-format must not be mounted in generation prompts"
);
assert!(names(&out).contains(&"schema"));
assert!(names(&out).contains(&"layout"));
}
// -----------------------------------------------------------------------
@ -464,10 +453,10 @@ mod tests {
let out = filter(input, ModelTier::Basic, false, false);
let got = names(&out);
assert!(!got.contains(&"examples"));
assert!(!got.contains(&"jsonl-format-simplified"));
assert!(got.contains(&"schema"));
assert!(got.contains(&"layout"));
assert!(got.contains(&"cjk-typography"));
assert!(got.contains(&"jsonl-format-simplified"));
}
#[test]
@ -539,7 +528,6 @@ mod tests {
// All retryAllowed skills that were in input should be present.
for expected in &[
"schema",
"jsonl-format-simplified",
"layout",
"text-rules",
"mobile-app",
@ -553,7 +541,7 @@ mod tests {
"'{expected}' should be in retryAllowed output"
);
}
assert_eq!(got.len(), 9, "exactly 9 skills in retryAllowed output");
assert_eq!(got.len(), 8, "exactly 8 skills in retryAllowed output");
}
#[test]
@ -562,8 +550,8 @@ mod tests {
let out = filter(input, ModelTier::Basic, false, true);
let got = names(&out);
assert!(!got.contains(&"elements"));
assert!(!got.contains(&"jsonl-format-simplified"));
assert!(got.contains(&"schema"));
assert!(got.contains(&"jsonl-format-simplified"));
assert!(got.contains(&"layout"));
}
@ -623,9 +611,10 @@ mod tests {
fn apply_skill_filter_reports_minimal_mode_drops() {
let input = skills(&["schema", "jsonl-format", "layout", "text-rules"]);
let (kept, dropped) = apply_skill_filter(input, ModelTier::Full, false, false, true, false);
assert_eq!(names(&kept), vec!["schema", "jsonl-format"]);
// layout + text-rules must be reported as MinimalMode drops.
assert_eq!(names(&kept), vec!["schema"]);
// jsonl-format + layout + text-rules must be reported as MinimalMode drops.
let dn: Vec<&str> = dropped.iter().map(|(n, _)| n.as_str()).collect();
assert!(dn.contains(&"jsonl-format"));
assert!(dn.contains(&"layout") && dn.contains(&"text-rules"));
assert!(dropped
.iter()
@ -633,8 +622,7 @@ mod tests {
}
#[test]
fn apply_skill_filter_reports_deduped_jsonl_format() {
// When jsonl-format-simplified survives, verbose jsonl-format is Deduped.
fn apply_skill_filter_reports_jsonl_format_drops() {
let input = skills(&[
"schema",
"jsonl-format",
@ -643,14 +631,11 @@ mod tests {
]);
let (kept, dropped) =
apply_skill_filter(input, ModelTier::Full, false, false, false, false);
assert!(names(&kept).contains(&"jsonl-format-simplified"));
assert!(!names(&kept).contains(&"jsonl-format-simplified"));
assert!(!names(&kept).contains(&"jsonl-format"));
let deduped: Vec<&str> = dropped
.iter()
.filter(|(_, r)| matches!(r, op_ai_skills::DropReason::Deduped))
.map(|(n, _)| n.as_str())
.collect();
assert!(deduped.contains(&"jsonl-format"));
let dn: Vec<&str> = dropped.iter().map(|(n, _)| n.as_str()).collect();
assert!(dn.contains(&"jsonl-format"));
assert!(dn.contains(&"jsonl-format-simplified"));
}
#[test]

View file

@ -614,17 +614,12 @@ const COMPONENT_CATEGORY_ORDER: &[&str] = &[
/// `component-composition` skill.
///
/// `script_on` picks which of the two ref dialects the trailing instruction
/// teaches — it MUST match the output protocol the rest of this prompt uses
/// (the same `script_on` that selects `SCRIPT_FORMAT` vs `NODE_FORMAT` below):
/// - `true` (script-gen, THE default protocol on the full first attempt) —
/// a single `I(<containerBinding>, {"type":"ref", ...})` call. There is no
/// hand-written `id` and no `_parent`: ids remap at parse, and a bare
/// `{"_parent":...}` line is never recorded by the script sandbox (only
/// `I(...)` calls are), so teaching the flat form here would make refs
/// silently vanish under script-gen.
/// - `false` (flat `_parent` JSONL — the reduced-complexity / minimal-skills
/// retry rungs, matching `NODE_FORMAT`) — a single
/// `{"_parent":...,"id":...,"type":"ref", ...}` line, as taught today.
/// teaches. The subagent path always passes `true`; `false` is retained only
/// for direct core callers/tests that still need the legacy NODE dialect.
/// - `true` (script-gen) — a single
/// `I(<containerBinding>, {"type":"ref", ...})` call.
/// - `false` (legacy flat `_parent` JSONL) — a single
/// `{"_parent":...,"id":...,"type":"ref", ...}` line.
fn available_components_manifest(components: &ComponentLibrary, script_on: bool) -> Option<String> {
if components.is_empty() {
return None;
@ -690,10 +685,9 @@ fn available_components_manifest(components: &ComponentLibrary, script_on: bool)
/// `elements` and other non-essential skills). For Standard/Full
/// tier this is a no-op. Port of the `reducedComplexity` param in
/// `executeSubAgent` (orchestrator-sub-agent.ts:349).
/// * `minimal_skills` — When `true`, strips the system prompt down to
/// only `schema` + `jsonl-format` (last-ditch fallback for models
/// whose safety scanner times out on the full prompt). Port of the
/// `minimalSkills` param in `executeSubAgent` (lines 428-431).
/// * `minimal_skills` — When `true`, strips the skill set down to only
/// `schema` (last-ditch fallback for models whose safety scanner times out
/// on the full prompt). The output protocol still comes from `SCRIPT_FORMAT`.
/// * `components` — the document's reusable-component registry. When
/// non-empty it injects an AVAILABLE COMPONENTS manifest + raises the
/// `hasReusableComponents` flag (loads the `component-composition`
@ -707,10 +701,9 @@ pub fn build_subagent_prompt(
minimal_skills: bool,
components: &ComponentLibrary,
) -> (CallRequest, SkillLoadReport) {
// Script-gen is THE protocol on the full first attempt, for every model —
// the reduced/minimal retry rungs teach raw JSONL instead (and
// `subagent::run_subtask` routes parsing to match).
let script_on = !reduced_complexity && !minimal_skills;
// Script-gen is THE subagent protocol on every rung. Retry flags narrow
// the skill set only; they never switch the output protocol.
let script_on = true;
build_subagent_prompt_core(
subtask,
plan,
@ -896,11 +889,10 @@ fn build_subagent_prompt_core(
);
(filtered, agent_ctx.report, filter_drops)
};
// Script-gen REPLACES the raw-JSONL output format — carrying the
// `jsonl-format` skill alongside it feeds the model two contradictory
// output contracts (e2e showed glm then emitting a half-program/half-
// `_parent` blob with unclosed objects). Drop it so SCRIPT_FORMAT governs
// alone.
// Script-gen REPLACES the raw-JSONL output format — carrying a JSONL skill
// alongside it feeds the model two contradictory output contracts. Keep
// this guard even though the JSONL skills are no longer mounted by the
// generation registry.
if script_on {
filtered.retain(|s| {
s.skill_name() != "jsonl-format" && s.skill_name() != "jsonl-format-simplified"
@ -913,10 +905,9 @@ fn build_subagent_prompt_core(
.collect::<Vec<_>>()
.join("\n\n");
system_prompt.push_str("\n\n");
// Flat `_parent` for ALL tiers — validated that Basic-tier models
// (MiniMax M2.7/M3 are Basic) emit clean `_parent` trees with it. The
// `jsonl-format` + `jsonl-format-simplified` skills both teach `_parent`,
// so this agrees with whichever skill the tier loads (no contradiction).
// The public subagent path always appends SCRIPT_FORMAT. The NODE_FORMAT
// branch is retained for direct core callers/tests that intentionally
// exercise the legacy dialect.
system_prompt.push_str(if script_on {
SCRIPT_FORMAT
} else {
@ -972,10 +963,9 @@ fn build_subagent_prompt_core(
.map(|instruction| format!("{instruction}\n\n"))
.unwrap_or_default();
// Two constraints differ by output protocol: script-gen has the model call
// `I(...)` to build a JS program with a system-assigned root binding; the
// raw-JSONL fallback (reduced/minimal retry rungs) has the model author
// its own root frame + ids.
// Two constraints differ by output protocol. The public subagent path uses
// the script-gen branch; the raw-JSONL branch is legacy-only for direct
// core callers.
let (root_rule, nesting_rule, output_rule) = if script_on {
(
format!("Create EXACTLY ONE section root frame first: const sec = I(null, {{type:\"frame\", name:\"{}\", width:\"fill_container\", height:\"fit_content\", layout:\"vertical\"}}); build everything else by calling I(parent, {{...}}) with `sec` or a returned id as parent. NEVER set a fixed pixel height on the root.", subtask.label),

View file

@ -143,10 +143,10 @@ fn subagent_prompt_carries_subtask_and_script_format() {
);
}
/// The reduced-complexity retry rung falls back to flat `_parent` JSONL
/// (NODE_FORMAT) — matching what `subagent::run_subtask` parses there.
/// The reduced-complexity retry rung keeps script-gen; only its skill set is
/// narrowed.
#[test]
fn subagent_prompt_reduced_complexity_carries_node_format() {
fn subagent_prompt_reduced_complexity_carries_script_format() {
let st = Subtask {
id: "hero".into(),
label: "Hero".into(),
@ -163,8 +163,13 @@ fn subagent_prompt_reduced_complexity_carries_node_format() {
};
let (cr, _) = bsp(&st, &plan(), &req(), AbortFlag::new(), true, false);
assert!(cr.user_prompt.contains("Hero"));
assert!(cr.user_prompt.contains("hero-"));
assert!(cr.system_prompt.contains("PenNode"));
assert!(!cr.user_prompt.contains("IDs prefix=\"hero-\""));
assert!(cr
.system_prompt
.contains("OUTPUT PROTOCOL: JAVASCRIPT PROGRAM"));
assert!(!cr.system_prompt.contains(
"Respond with THIS section's canonical PenNode objects in the FLAT _parent format"
));
}
#[test]
@ -218,8 +223,7 @@ fn subagent_prompt_carries_ts_layout_contract() {
// wording and script-gen's `width:"fill_container"` JS-object wording —
// the root-frame AUTHORING convention itself (`Root frame: id="..."` vs
// `const sec = I(null, ...)`) is protocol-specific and covered by the
// dedicated `subagent_prompt_carries_subtask_and_script_format` /
// `subagent_prompt_reduced_complexity_carries_node_format` tests.
// dedicated `subagent_prompt_carries_subtask_and_script_format` tests.
for required in required.split('|') {
assert!(
cr.system_prompt.contains(required) || cr.user_prompt.contains(required),
@ -229,7 +233,7 @@ fn subagent_prompt_carries_ts_layout_contract() {
}
#[test]
fn subagent_prompt_minimal_skills_only_has_schema_and_jsonl() {
fn subagent_prompt_minimal_skills_has_schema_and_script_format() {
let st = Subtask {
id: "hero".into(),
label: "Hero".into(),
@ -244,13 +248,19 @@ fn subagent_prompt_minimal_skills_only_has_schema_and_jsonl() {
generated_root_id: None,
existing_section_labels: None,
};
// minimal_skills=true: the system prompt should contain "schema" skill
// content and "jsonl-format" skill content, but NOT layout/text-rules etc.
// minimal_skills=true: the system prompt should contain schema skill
// content plus the script-gen protocol suffix, but NOT layout/text-rules
// or the retired jsonl-format skill.
let (cr, _) = bsp(&st, &plan(), &req(), AbortFlag::new(), false, true);
// schema and jsonl-format skills should appear (they always exist)
assert!(
cr.system_prompt.contains("PenNode"),
"NODE_FORMAT suffix should still be appended"
cr.system_prompt
.contains("OUTPUT PROTOCOL: JAVASCRIPT PROGRAM"),
"minimal_skills prompt should still append SCRIPT_FORMAT"
);
assert!(
!cr.system_prompt
.contains("CRITICAL — OUTPUT FORMAT: Emit raw JSONL only"),
"minimal_skills prompt must not mount jsonl-format"
);
// The system_prompt should be considerably shorter than a full-skill prompt
let (full_cr, _) = bsp(&st, &plan(), &req(), AbortFlag::new(), false, false);
@ -297,12 +307,9 @@ fn subagent_prompt_reduced_complexity_basic_is_shorter_than_full() {
);
}
/// Before the Task 4 protocol collapse, `reduced_complexity` only drove
/// tier-gated SKILL narrowing (a no-op for Full tier). It now ALSO selects
/// the output protocol (script-gen on the full attempt, flat JSONL on the
/// reduced rung) for EVERY tier — so holding `script_on` fixed via the core
/// fn isolates the still-true "Full tier skill narrowing is a no-op"
/// invariant from that new, tier-independent protocol switch.
/// `reduced_complexity` only drives tier-gated SKILL narrowing. Holding
/// `script_on` fixed via the core fn isolates the still-true "Full tier skill
/// narrowing is a no-op" invariant.
#[test]
fn subagent_prompt_reduced_complexity_full_tier_skill_filtering_is_noop() {
let st = Subtask {
@ -348,12 +355,11 @@ fn subagent_prompt_reduced_complexity_full_tier_skill_filtering_is_noop() {
);
}
/// The new, tier-independent half of the same invariant: with `script_on`
/// left to its normal `reduced_complexity`-derived value, the full attempt
/// and the reduced rung now legitimately differ — even on Full tier — because
/// `reduced_complexity` also flips the output protocol.
/// Public prompt construction keeps script-gen even when `reduced_complexity`
/// is enabled; on Full tier the skill set is also unchanged, so the prompt is
/// byte-for-byte equal to the full attempt.
#[test]
fn subagent_prompt_reduced_complexity_flips_protocol_even_on_full_tier() {
fn subagent_prompt_reduced_complexity_keeps_script_gen_even_on_full_tier() {
let st = Subtask {
id: "hero".into(),
label: "Hero".into(),
@ -378,24 +384,31 @@ fn subagent_prompt_reduced_complexity_flips_protocol_even_on_full_tier() {
);
assert!(
reduced_cr.system_prompt.contains("PenNode"),
"reduced rung falls back to flat JSONL even on Full tier"
"schema skill should still describe PenNode"
);
assert_ne!(full_cr.system_prompt, reduced_cr.system_prompt);
assert!(
reduced_cr
.system_prompt
.contains("OUTPUT PROTOCOL: JAVASCRIPT PROGRAM"),
"reduced rung must stay on script-gen"
);
assert!(
!reduced_cr.system_prompt.contains(
"Respond with THIS section's canonical PenNode objects in the FLAT _parent format"
),
"reduced rung must not append NODE_FORMAT"
);
assert_eq!(full_cr.system_prompt, reduced_cr.system_prompt);
}
/// Regression guard for the flag-passing fix: a Basic-tier model must load
/// `jsonl-format-simplified` (gated by the `isBasicTier` flag) and drop the
/// verbose `jsonl-format`, while a Full-tier model keeps the verbose one.
/// Before the fix, `resolve_generation_skills` passed empty flags, so the
/// simplified skill could NEVER load for the weak models it targets.
/// Regression guard for retiring the flat JSONL generation protocol: Basic
/// reduced-complexity retries still narrow the skill set, but the subagent
/// prompt must not mount either JSONL output-format skill.
#[test]
fn subagent_prompt_basic_tier_swaps_in_simplified_format_skill() {
// Verbose-only marker (lives solely in jsonl-format.md) and simplified-only
// marker (the parenthesized rectangle arg list lives solely in
// jsonl-format-simplified.md). Both `jsonl-format` skills are dropped
// outright on the script-gen full attempt (SCRIPT_FORMAT governs the
// output contract alone), so this exercises the reduced-complexity JSONL
// rung, where the tier-based swap is actually live.
fn subagent_prompt_basic_tier_reduced_retry_drops_jsonl_format_skills() {
// Historical JSONL-only markers. The files are no longer mounted in the
// generation corpus, and this reduced retry should not reintroduce their
// wording through any compact-skill path.
const VERBOSE_ONLY: &str = "imageSearchQuery MUST be UNIQUE";
const SIMPLIFIED_ONLY: &str = "rectangle (width,height,cornerRadius,fill)";
@ -409,7 +422,7 @@ fn subagent_prompt_basic_tier_swaps_in_simplified_format_skill() {
validation_enabled: true,
visual_ref_enabled: false,
};
let (basic_cr, _) = bsp(
let (basic_cr, basic_report) = bsp(
&subtask(),
&plan(),
&basic_req,
@ -417,24 +430,33 @@ fn subagent_prompt_basic_tier_swaps_in_simplified_format_skill() {
true,
false,
);
// req() is model "claude" → Full tier.
let (full_cr, _) = bsp(&subtask(), &plan(), &req(), AbortFlag::new(), true, false);
let included: Vec<&str> = basic_report
.included
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(
basic_cr.system_prompt.contains(SIMPLIFIED_ONLY),
"Basic tier must load jsonl-format-simplified"
!included.contains(&"jsonl-format-simplified"),
"Basic reduced retry must not mount jsonl-format-simplified"
);
assert!(
!included.contains(&"jsonl-format"),
"Basic reduced retry must not mount jsonl-format"
);
assert!(
!basic_cr.system_prompt.contains(SIMPLIFIED_ONLY),
"Basic reduced retry must not carry simplified JSONL wording"
);
assert!(
!basic_cr.system_prompt.contains(VERBOSE_ONLY),
"Basic tier must NOT carry the verbose jsonl-format (deduped by simplified)"
"Basic reduced retry must not carry verbose JSONL wording"
);
assert!(
full_cr.system_prompt.contains(VERBOSE_ONLY),
"Full tier must keep the verbose jsonl-format"
);
assert!(
!full_cr.system_prompt.contains(SIMPLIFIED_ONLY),
"Full tier must NOT load jsonl-format-simplified (isBasicTier is false)"
basic_cr
.system_prompt
.contains("OUTPUT PROTOCOL: JAVASCRIPT PROGRAM"),
"Basic reduced retry must still append SCRIPT_FORMAT"
);
}
@ -1251,9 +1273,8 @@ fn large_component_library_is_capped() {
/// Regression guard for the protocol-mismatch stop-gate: the AVAILABLE
/// COMPONENTS manifest's trailing ref-syntax example must match whichever
/// output protocol governs the REST of this prompt — script-gen (`I(...)`
/// calls) on the full attempt, flat `_parent` JSONL on the
/// reduced-complexity retry rung. Teaching the wrong dialect makes every
/// output protocol governs the REST of this prompt. The subagent path is now
/// script-gen on every retry rung. Teaching the wrong dialect makes every
/// `ref` silently vanish: a bare `{"_parent":...}` line is never recorded by
/// the script sandbox (only `I(...)` calls are), so under script-gen the
/// old always-flat instruction taught the model a no-op.
@ -1290,8 +1311,8 @@ fn components_manifest_instruction_matches_active_protocol() {
— a bare {{\"_parent\":...}} line is never recorded by the script sandbox:\n{script_sys}"
);
// (b) Reduced-complexity retry rung: falls back to flat `_parent` JSONL.
// The manifest must teach the `_parent` ref line.
// (b) Reduced-complexity retry rung: still script-gen. The manifest must
// teach the `I(...)` ref call and must not teach the `_parent` ref line.
let (flat_cr, _) = build_subagent_prompt(
&subtask(),
&plan(),
@ -1303,8 +1324,12 @@ fn components_manifest_instruction_matches_active_protocol() {
);
let flat_sys = &flat_cr.system_prompt;
assert!(
flat_sys.contains("\"_parent\":\"<container-id>\""),
"reduced-complexity manifest must teach the flat _parent ref line:\n{flat_sys}"
flat_sys.contains("I(<containerBinding>, {\"type\":\"ref\""),
"reduced-complexity manifest must keep the script-gen ref call:\n{flat_sys}"
);
assert!(
!flat_sys.contains("\"_parent\":\"<container-id>\""),
"reduced-complexity manifest must not teach the flat _parent ref line:\n{flat_sys}"
);
}
@ -1423,23 +1448,22 @@ fn basic_tier_components_prompt_keeps_both_manifest_and_teaching() {
);
}
/// Regression guard for the BUDGET-drop bug — the non-mobile dashboard path.
/// Regression guard for the non-mobile Basic dashboard component path.
///
/// The earlier `basic_tier_components_prompt_keeps_both_*` test covers the
/// MOBILE path (9200-token budget with headroom), which only ever exercised the
/// TIER allow-set drop. The real loss in production is on the NON-MOBILE,
/// Basic-tier dashboard path: `budget_max = 5200`, base skills alone consume
/// ~3900, so the flag-gated `component-composition` skill (~1200 tok) does NOT
/// fit and was dropped with `DropReason::BudgetExhausted` — the model got the
/// AVAILABLE COMPONENTS list but no `ref` + `descendants` teaching and emitted 0
/// instances (`("component-composition","budget")` ×4 subtasks across runs).
/// TIER allow-set drop. The non-mobile Basic path still runs under the smaller
/// `budget_max = 5200`, where optional dashboard/depth skills can be
/// budget-dropped. With a component library loaded, the model must still get
/// both halves of the component contract: the AVAILABLE COMPONENTS list and the
/// `component-composition` teaching.
///
/// The force-include pin (prompt.rs: `pinned_skills` when `has_reusable_components`,
/// threaded into `trim_by_budget_pinned`) keeps it budget-exempt. This test
/// reproduces the EXACT scenario that dropped it (wide plan ⇒ 5200 budget, a
/// library present, budget already exhausted) and asserts the teaching survives.
/// threaded into `trim_by_budget_pinned`) keeps it budget-exempt in tighter
/// prompts. This test covers the current wide-plan Basic path after retiring
/// the JSONL skills that used to consume part of the budget.
#[test]
fn tight_budget_dashboard_force_includes_component_composition() {
fn tight_budget_dashboard_keeps_component_composition() {
// Basic tier is the path that overrides the budget down to 5200 when the
// plan is NOT a mobile full screen (the bug surface).
assert_eq!(
@ -1449,7 +1473,7 @@ fn tight_budget_dashboard_force_includes_component_composition() {
);
// A wide (non-mobile) dashboard plan → is_mobile_full_screen = false →
// budget_override = Some(5200), the exact tight path that budget-dropped it.
// budget_override = Some(5200), the tight path for Basic models.
let basic_req = DesignRequest {
prompt: "Design a 1280x800 analytics dashboard with metric cards, \
a chart panel, and a data table using the available components"
@ -1476,11 +1500,9 @@ fn tight_budget_dashboard_force_includes_component_composition() {
};
let lib = library_with(5);
// Drive the core with script_on forced OFF so this exercises the
// FLAT-JSONL tight-budget path the test is about — deterministically,
// regardless of the model's default protocol. (script-gen is THE default
// on the full attempt and drops the jsonl-format skill, freeing budget;
// that would un-exhaust the 5200 budget and void the pin scenario.)
// Drive the core with script_on forced OFF so this still exercises the
// legacy NODE-dialect branch deterministically. Public subagent prompts do
// not call this branch.
let (cr, report) = build_subagent_prompt_core(
&dash_subtask,
&dash_plan,
@ -1493,18 +1515,17 @@ fn tight_budget_dashboard_force_includes_component_composition() {
);
let sys = &cr.system_prompt;
// (0) Prove this is the TIGHT path: the 5200 budget is genuinely exhausted —
// budget_used >= budget_max — so the survival of component-composition can
// ONLY be the force-include pin, not leftover headroom. (Before the fix this
// same exhaustion is what dropped it with DropReason::BudgetExhausted.)
// (0) Prove this is the non-mobile Basic tight path.
assert_eq!(
report.budget_max, 5200,
"non-mobile Basic must use the 5200 budget"
);
assert!(
report.budget_used >= report.budget_max,
"fixture must EXHAUST the budget so the pin is the only thing keeping the \
skill (the bug dropped it here); report={report:?}"
report
.dropped
.iter()
.any(|s| matches!(s.reason, op_ai_skills::DropReason::BudgetExhausted)),
"fixture must still exercise budget pressure; report={report:?}"
);
// (1) The component-composition TEACHING skill survived the tight budget.

View file

@ -7,7 +7,6 @@
//! - `node_count == 0` —— 零节点失败,调用方应停止后续 subtask;
//! - `node_count > 0`(`error` 可带软错误)—— 部分产出,继续后续。
use crate::parse::parse_nodes;
use crate::plan::{OrchestratorPlan, Subtask};
use crate::prompt::build_subagent_prompt;
use crate::types::{AbortFlag, DesignRequest, DocSink, LlmChunk, LlmClient, SubtaskOutcome};
@ -25,9 +24,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
/// 8-skill set when the model is Basic tier. Pass `false` for the
/// first attempt; pass `true` on the second attempt of the retry
/// ladder (Task C3).
/// * `minimal_skills` — Strip the system prompt to only
/// `schema`+`jsonl-format` (last-ditch fallback). Pass `false` for
/// the first two attempts; pass `true` on the third attempt (Task C3).
/// * `minimal_skills` — Strip the skill set to only `schema`; the output
/// protocol remains script-gen via `SCRIPT_FORMAT`. Pass `false` for the
/// first two attempts; pass `true` on the third attempt (Task C3).
#[allow(clippy::too_many_arguments)]
pub async fn run_subtask(
subtask: &Subtask,
@ -164,41 +163,22 @@ pub(crate) async fn run_subtask_with_reveal_at(
"subagent text collected"
);
// Script-gen is THE protocol on the full first attempt; the reduced /
// minimal retry rungs teach raw JSONL, so parsing falls back to
// `parse_nodes` there (matching the prompt in build_subagent_prompt).
// Script-gen is THE protocol on every subagent rung. Reduced/minimal
// retries narrow the skill set only; they never switch to flat JSONL.
// `program_state` carries any doc-root `state` script-gen's underlying
// `run_program_to_forest` hoisted on the SCRATCH document it builds the
// forest against (see `program_gen`'s module doc) — the flat-JSONL rung
// has no such hoist, so it stays an empty schema there.
let script_on = !reduced_complexity && !minimal_skills;
let (mut nodes, program_state) = if script_on {
match crate::script_gen::parse_script(&text) {
Ok(n) => n,
Err(e) => {
tracing::warn!(
subtask = %subtask.id,
text_len = text.len(),
thinking_len,
raw = %text,
"subagent script-gen parse failed"
);
return fail(e);
}
}
} else {
match parse_nodes(&text) {
Ok(n) => (n, jian_ops_schema::state::StateSchema::new()),
Err(e) => {
tracing::warn!(
subtask = %subtask.id,
text_len = text.len(),
thinking_len,
raw = %text,
"subagent parse failed"
);
return fail(e.to_string());
}
// forest against (see `program_gen`'s module doc).
let (mut nodes, program_state) = match crate::script_gen::parse_script(&text) {
Ok(n) => n,
Err(e) => {
tracing::warn!(
subtask = %subtask.id,
text_len = text.len(),
thinking_len,
raw = %text,
"subagent script-gen parse failed"
);
return fail(e);
}
};
if is_blank_container_forest(&nodes) {
@ -762,15 +742,11 @@ mod tests {
}
}
// Raw flat/nested-children JSONL fixture — only the reduced-complexity
// retry rung parses this (script-gen is the default on the full attempt;
// see `run_subtask_reduced_complexity_falls_back_to_flat_jsonl` below).
const NODE_JSON: &str = r#"[{"type":"frame","id":"hero-1","name":"Card","x":0,"y":0,"width":1200,"height":200,"children":[{"type":"text","id":"hero-title","content":"Hero","fontSize":18}]}]"#;
// Script-gen equivalent of `NODE_JSON` — a single I(null, {...}) call whose
// node object nests its children inline (batch_design's insert accepts a
// whole subtree per call). Authored ids are dropped: the batch_design
// executor reassigns fresh ids to every inserted node regardless, so tests
// that use this constant must not assert on literal id strings.
// A single I(null, {...}) call whose node object nests its children inline
// (batch_design's insert accepts a whole subtree per call). Authored ids
// are dropped: the batch_design executor reassigns fresh ids to every
// inserted node regardless, so tests that use this constant must not assert
// on literal id strings.
const NODE_SCRIPT: &str = r#"I(null, {"type":"frame","name":"Card","x":0,"y":0,"width":1200,"height":200,"children":[{"type":"text","content":"Hero","fontSize":18}]});"#;
#[test]
@ -1114,13 +1090,12 @@ mod tests {
));
}
/// Reduced-complexity retry rung teaches raw JSONL, NOT script-gen — this is
/// a regression guard for the protocol collapse: `subagent::run_subtask`
/// must route parsing to `parse_nodes` there (matching `build_subagent_prompt`)
/// and the flat path must still build a forest end-to-end.
/// Reduced-complexity retry rung still uses script-gen; it narrows the
/// skill set only. The parser must therefore accept the same nested
/// `I(parent, node)` forest as the full attempt.
#[test]
fn run_subtask_reduced_complexity_falls_back_to_flat_jsonl() {
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(NODE_JSON.into())]);
fn run_subtask_reduced_complexity_still_uses_script_gen_nested_forest() {
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(NODE_SCRIPT.into())]);
let mut sink = VecDocSink::new();
let outcome = block_on(run_subtask(
&subtask(),
@ -1137,10 +1112,18 @@ mod tests {
let Some(EditorCommand::InsertSubtree { nodes, .. }) = sink.applied.last() else {
panic!("expected InsertSubtree, got {:?}", sink.applied.last());
};
// The flat-JSONL path keeps the model-authored id verbatim (no
// batch_design remap), unlike script-gen — proving the two retry
// rungs still diverge as designed.
assert_eq!(nodes[0].id_str(), "hero-1");
assert_eq!(nodes.len(), 1);
let children = nodes[0].children().expect("script-gen frame has children");
assert_eq!(children.len(), 1);
assert!(
!nodes[0].id_str().is_empty(),
"script-gen must assign a fresh root id"
);
assert_ne!(
nodes[0].id_str(),
"hero-1",
"reduced retry must not use the retired flat-JSONL parser"
);
}
#[test]