Skip to content

feat: Deferred skills + skill search: load only a few top skills at start, update the system prompt to look up skills (scale t#394

Merged
andrei-hasna merged 6 commits into
mainfrom
factory/3e8a1d71-3890-4d38-b08e-02cddc43-51912f50
Jul 25, 2026
Merged

feat: Deferred skills + skill search: load only a few top skills at start, update the system prompt to look up skills (scale t#394
andrei-hasna merged 6 commits into
mainfrom
factory/3e8a1d71-3890-4d38-b08e-02cddc43-51912f50

Conversation

@andrei-hasna

Copy link
Copy Markdown
Contributor

Objective

Deferred skills + skill search: load only a few top skills at start, update the system prompt to look up skills (scale to thousands)

Do not load all skills. Load only a few top skills initially and make most skills deferred (not all injected). Add a skill-search mechanism and update the system prompt to always look for the relevant skills (consider deterministic user-prompt skill retrieval as a fallback). Research the proper approach — there may eventually be thousands of skills or more. The agent MUST start a goal plan for this task and drive it node by node (this is required). Investigate the current codewith implementation first and cite file:line. Build/test on remote Blacksmith only (never compile locally); keep cargo fmt (--config imports_granularity=Item), clippy --tests --all-targets -D warnings, and prettier clean; land a PR into hasna/codewith main.

Verification

  • install: pass

Run run_32621b0ae5f3 · backend codewith · task 3e8a1d71-3890-4d38-b08e-02cddc43242d
🏭 Generated by @hasnaxyz/factory

@andrei-hasna
andrei-hasna force-pushed the factory/3e8a1d71-3890-4d38-b08e-02cddc43-51912f50 branch 3 times, most recently from aa3fb4b to a14d3ce Compare July 25, 2026 04:56
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Rebase + Blacksmith testbox verification

Rebased onto origin/main (54333c9e0, which now carries #395, #402, #403, #401, #411 and the blacksmith-testbox.yml workflow) and force-pushed. Net diff is byte-identical to the pre-rebase diff for the 19 product/test files; the only dropped commit is aa3fb4b06 ("page the background-agent journal instead of assuming one page"), which is superseded by main: #395 already raised every agent_events_page limit in that test to Some(200) (= MAX_AGENT_LIST_LIMIT), so the paging workaround is no longer needed.

Blacksmith testbox — run 30145401580 · conclusion=success · sha a14d3ce

cd codex-rs \
  && cargo clippy --tests --all-targets -p codex-core-skills -p codex-skills-extension \
       -p codex-app-server -p codex-mcp-server -p codex-core \
  && cargo nextest run -p codex-core-skills -p codex-skills-extension \
  && cargo nextest run -p codex-core -E 'test(skill) - test(shell_snapshot)' \
  && cargo nextest run -p codex-app-server \
       -E 'test(agent_start_list_read_and_events_survive_app_server_restart)'
  • clippy: clean across all five crates
  • codex-core-skills + codex-skills-extension: 109/109 passed
  • codex-core skill tests: 24/24 passed
  • codex-app-server restart/journal test: 1/1 passed — confirms the journal assertion holds with the skills extension installed, i.e. that dropping aa3fb4b06 is safe

Two exclusions, both justified:

  1. suite::shell_snapshot::shell_snapshot_deleted_after_shutdown_with_skills — matched only because of the skills substring in its name (it exercises shell snapshots, not the skills catalog). Baseline run 30145403390 ran that one test on unmodified origin/main on the same runner class and it failed identically (timed out waiting for shell snapshot). Pre-existing environment limitation, not a regression from this PR.
  2. -- -D warnings — run 30144894390 shows it fails on untouched codex-code-mode (clippy::while_let_loop). This repo gates on the explicit [workspace.lints.clippy] deny-list, not blanket -D warnings, so the run above uses the repo-native just clippy semantics (cargo clippy --tests).

Full PR CI on the rebased head: 38 pass / 2 skipping / 2 pending (the multi-hour Windows ptrcomp-sandbox from-source builds).

Follow-up for the reviewer (not fixed here, to keep the diff scoped)

Wiring codex_skills_extension::install into app-server means that on a turn whose text lexically matches a skill, the model receives the <skills_instructions> block twice: once as the developer-message starter list from core/src/session/mod.rs:3269 (build_available_skills) and once as the extension's ranked contextual-user fragment (ext/skills/src/extension.rs:154), because both render the full render_available_skills_body how-to-use preamble and both are gated on the same config.include_skill_instructions. Explicit skill bodies are correctly de-duplicated via InjectedHostSkillPrompts (core/src/session/turn.rs:760); only the list preamble is duplicated. Worth collapsing in a follow-up.

@andrei-hasna
andrei-hasna force-pushed the factory/3e8a1d71-3890-4d38-b08e-02cddc43-51912f50 branch from a14d3ce to c5f00c7 Compare July 25, 2026 06:45
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Round 2 — design defects addressed. Head c5f00c75e9b88c4365ad74430646298f4a0ab188 (rebased onto origin/main @ b71a05e; 0 behind).

BLOCKER 1 — cap fires when not needed. MAX_STARTER_SKILLS is no longer applied unconditionally. starter_limit_for() returns the full catalog size unless (a) the catalog genuinely does not fit the skills context budget — checked under both the absolute-path and aliased-path layouts via full_catalog_fits_budget() — and (b) a catalog-search tool exists. build_available_skills_with_starter_cap() exposes the ceiling for embedders. core/tests/suite/client.rs is back on the original 12_000 window and now additionally asserts all 12 skills render; a deterministic core-skills mirror (uncapped_catalog_still_aliases_every_skill_under_budget_pressure) proves the same aliasing path without needing the network harness. budget_negotiation_still_runs_on_the_starter_subset proves aliasing + description truncation still run on the capped subset, so the budget machinery is not dead code either way.

BLOCKER 2 — escape hatch cannot escape. MAX_SKILL_MATCH_LIMIT 5 → 50 (a compile-time const _: () = assert!(MAX > DEFAULT) pins the invariant), plus a new offset argument (max 1000) and a next_offset cursor on the response. skills_list_pages_past_the_default_limit walks a 24-entry catalog end-to-end through the cursor and asserts every entry is reachable, that limit: 50 widens the page, and that paging past the end terminates cleanly.

BLOCKER 3 — wrong copy de-quadratified. catalog_from_outcome() now hashes via SkillCatalog::extend instead of push_entry. Regression test catalog_from_outcome_stays_linear_at_catalog_scale builds a 20k-entry host catalog of long common-prefix paths under a wall-clock budget that a per-insert linear scan cannot meet.

DEFECT 4 — observability. New codex.thread.skills.deferred_total metric, deferred_count plumbed through record_skill_render_side_effects(), and codex.thread.skills.truncated now also flags deferral — a 2105 → 5 drop can no longer be reported alongside truncated = 0.

DEFECT 5 — duplicated preamble. The per-turn ranked fragment renders a new compact render_task_relevant_skills_body() (## Task-relevant skills) that omits the ~2.5k-char ### How to use skills block already carried by the developer message. Asserted in both core-skills and the extension integration test.

DEFECT 6 — latent trap. codex-core-skills now owns the canonical skills / list tool names; the extension consumes them, and skill_catalog_search_availability() derives SkillCatalogSearch from the thread’s actual extension tools. An embedder built with empty_extension_registry() therefore always gets the complete list. starter_cap_does_not_fire_without_a_catalog_search_tool covers it — and this is precisely why the restored 12_000 client test passes.

DEFECT 7 — recall. Added light Porter-style step-1a/1b stemming (plurals + -ing/-ed), normalized on both sides so matching stays O(1) hash intersection — no quadratic prefix/fuzzy scan. inflected_query_terms_still_reach_their_skill covers chartschart; paraphrased_queries_do_not_reach_lexically_disjoint_skills documents that synonym paraphrase still returns nothing and says to flip the assertion when recall improves.

Also: (a) installed_extension_injects_available_catalog_and_selected_entrypoint no longer asserts is_empty() on turn 2 — it now asserts the injected-catalog fragment reappears on a matching follow-up turn, with a separate turn-3 case documenting the lexically-disjoint behaviour. (b) CHANGELOG updated under [Unreleased]. (c) Rebased onto origin/main.

Gate — Blacksmith Testbox run 30148059491, head c5f00c75e, conclusion success:

  • cargo clippy --tests --all-targets over codex-core-skills, codex-skills-extension, codex-app-server, codex-mcp-server, codex-core — clean
  • codex-core-skills + codex-skills-extension: 120/120 passed
  • codex-core -E 'test(skill) - test(shell_snapshot)': 24/24 passed (includes the restored 12k-window alias test)
  • codex-app-server -E 'test(agent_start_list_read_and_events_survive_app_server_restart)': 1/1 passed

…tart, update the system prompt to look up skills (scale t
…tent index

- codex-rs/ext/skills declared `test = false` on its lib target while the new
  `ranking` module adds `#[cfg(test)]` unit tests, which made cargo emit a
  warning and `cargo shear --deny-warnings` fail. Sibling ext crates that carry
  in-source tests (memories, web-search, workflows, image-generation) omit the
  flag, so drop it here too.
- The skills extension is now installed in the app-server registry, so a
  `<skills_instructions>` contextual fragment can precede the user text in the
  request payload. Assert the combined user text is present among the user
  content segments instead of pinning it to index 0.
The skills extension is now installed in the app-server registry, so a
<skills_instructions> contextual fragment is appended as an additional user
input item after the user message. Search every user content segment for the
combined text instead of pinning it to the last item.
…em skills

The starter cap took the first `MAX_STARTER_SKILLS` entries of the
scope-then-name ordering. Every session also loads the five bundled `System`
skills, which sort first, so the starter list was always 100% built-ins and a
workspace's own Repo/User/Admin (and plugin) skills never reached the
model-visible list at all.

Deal the starter slots round-robin across the scopes that are present, then
render the winners in the usual deterministic order. Adds a unit test for the
starvation case.

Also retunes the core alias budget-pressure test: only five of its twelve
skills now reach the developer message, so the context window has to be
tighter for those starter entries to still exceed the 2% skills budget and
exercise the alias rendering path.
…achable

Round-2 review fixes for the deferred-skills change. The cap now only fires
when it buys something, and everything it hides is genuinely reachable.

- The `MAX_STARTER_SKILLS` cap is no longer unconditional. It applies only
  when the whole prompt-visible catalog cannot be rendered inside the skills
  context budget (checked under both the absolute-path and aliased-path
  layouts) *and* the thread exposes a `skills.list` catalog-search tool. A
  workspace with a dozen skills on a 200k window renders all of them again,
  and the budget machinery (aliasing, description truncation, omission
  warnings) is back on the live path instead of being near-dead code.
  `build_available_skills_with_starter_cap` exposes the ceiling for embedders.
- Tie the cap to the tool it depends on. `codex-core-skills` now owns the
  canonical `skills`/`list` tool names, the extension uses them, and core
  derives `SkillCatalogSearch` from the thread's actual extension tools. An
  embedder built with `empty_extension_registry()` can no longer be shown a
  truncated list it has no way to search past.
- `skills.list` can widen and page: `limit` default 5 / max 50, new `offset`
  (max 1000), and a `next_offset` cursor in the response. The old
  default == max made `limit` vestigial and left matches 6..N unreachable.
- De-quadratify the real hot path. `catalog_from_outcome` runs on every turn
  and de-duplicated with a linear scan per insert; because every host entry
  shares one authority, each comparison degraded into a full string compare of
  long common-prefix paths. Adds a 20k-entry regression test.
- Surface deferral in telemetry: new `codex.thread.skills.deferred_total`, and
  `codex.thread.skills.truncated` now also flags deferral, so a 2105 -> 5 drop
  can no longer be reported alongside `truncated = 0`.
- Stop duplicating the ~2.5k-char `How to use skills` preamble. The per-turn
  ranked fragment renders a compact `## Task-relevant skills` body and defers
  to the developer-message block for the shared guidance.
- Improve and pin down retrieval recall: light Porter-style plural/verb-form
  stemming so `charts` finds a `chart` skill, plus a test documenting that
  synonym paraphrases still return nothing.
- Restore the `skills_use_aliases_in_developer_message_under_budget_pressure`
  12k context window and assert all 12 skills render; add a deterministic
  core-skills mirror of it. Restore follow-up-turn catalog coverage in
  `installed_extension_injects_available_catalog_and_selected_entrypoint`.
- Update CHANGELOG.
@andrei-hasna
andrei-hasna force-pushed the factory/3e8a1d71-3890-4d38-b08e-02cddc43-51912f50 branch from c5f00c7 to a5bd59c Compare July 25, 2026 08:15
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@andrei-hasna
andrei-hasna force-pushed the factory/3e8a1d71-3890-4d38-b08e-02cddc43-51912f50 branch 2 times, most recently from 73dd117 to 55edb99 Compare July 25, 2026 08:18
…ng about it

Round-3 review fixes on top of the deferred-skills work.

- Remove the offset ceiling. `skills.list` walked at most rank 1049
  (MAX_SKILL_MATCH_OFFSET 1000 + MAX_SKILL_MATCH_LIMIT 50), so on a
  2000-skill catalog 47.5% of the entries were unreachable by any
  offset/limit combination. Ranking is now bounded by the catalog only.
- Never emit a cursor the tool rejects. Following `next_offset` verbatim
  used to hit `offset must be at most 1000`; deep offsets are now answered
  with a well-formed page, `next_offset` strictly advances, and new
  `total_matches`/`has_more` fields separate exhaustion from a partial page.
- Add an enumeration escape hatch. `query` is optional: omit it (or pass an
  empty string) to page the catalog alphabetically. Lexical ranking cannot
  reach a skill whose wording the model has to guess, so enumeration is the
  only complete path.
- Prefer truncating descriptions over hiding skills. Deferral now fires only
  when the catalog cannot be listed even without descriptions; a realistic
  twelve-skill workspace with 1536-char descriptions rendered five and
  silently dropped seven. Deferral also always warns.
- Carry the rules when there is no `## Skills` block. Core builds that block
  from the host outcome while the extension ranks the merged catalog, so a
  remote/executor-only catalog left the model with bare skill lines and a
  pointer to a section that was never written.
- Make the intro conditional. A complete list no longer claims to be a subset,
  and `skills.list` is not advertised on threads that do not install it.
- Stop diluting the description-truncation metric with the full catalog size,
  and assert the new deferred_total metric.
- Delete the callerless O(n)-per-insert `SkillCatalog::push_entry`.
@andrei-hasna
andrei-hasna force-pushed the factory/3e8a1d71-3890-4d38-b08e-02cddc43-51912f50 branch from 55edb99 to 895feea Compare July 25, 2026 08:59
@andrei-hasna
andrei-hasna merged commit 4433556 into main Jul 25, 2026
43 checks passed
@andrei-hasna
andrei-hasna deleted the factory/3e8a1d71-3890-4d38-b08e-02cddc43-51912f50 branch July 25, 2026 09:50
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant