Skip to content

Replace pre-LLM skill routing with LLM-orchestrated search_skills (closes #1130) - #1149

Merged
malibio merged 3 commits into
mainfrom
worktree-issue-1130-llm-skill-search
May 18, 2026
Merged

malibio merged 3 commits into
mainfrom
worktree-issue-1130-llm-skill-search

Conversation

@malibio

@malibio malibio commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #1130. Replaces the pre-LLM skill router with an LLM-callable search_skills(query, limit?) tool. The model now decides when to look for a skill, judges the raw confidence scores it gets back, and either invokes a matched skill or responds without one. Multi-skill turns and context-aware clarifications become possible because the model can call search_skills more than once per turn and reference what it searched when asking the user for help.

Also takes the opportunity to fix a perf footgun the prior code flagged with a TODO(perf): skill search now skips HNSW entirely and runs a direct cosine scan over only the skill-typed embeddings (record-link traversal via node.node_type = 'skill'). With a tiny candidate set (~8-20 skill nodes) this is both faster and exact, sidestepping HNSW's global-top-K + post-filter approximation.

What changed

Agent layer (packages/agent/)

  • search_skills exposed every turn — renamed from find_skills with a flatter response ({query, matches: [...]}) carrying raw confidence floats; HIGH/MEDIUM bucketing and recommendation strings dropped so the model judges.
  • Pre-LLM dispatch deleted: is_ambiguous heuristic, CLARIFYING_QUESTION constant, scoped-prompt branch, SkillPipeline::find_skill, scope_tools, SkillMatch, SkillPipelineConfig, and the intent module (only consumer was the removed pipeline).
  • Empty model output with no tool calls now surfaces as InferenceError (inference bug, not UX surface).
  • SkillPipeline collapsed to a thin wrapper that re-exports the seed templates used by first-run DB seeding (the seed definitions themselves are explicitly out-of-scope per the issue).

Storage layer (packages/core/)

  • SurrealStore::search_embeddings_by_node_type — linear cosine scan filtered through the existing embedding.node record link with the same chunk aggregation + density-ratio scoring as search_embeddings. No schema change required.
  • EmbeddingService::semantic_search_nodes_of_type — thin service-layer wrapper.
  • skill_ops::find_skills rewritten to use the typed scan and return the flat shape. MCP find_skills handler inherits the new shape transparently.

Prompts (packages/agent/src/{agent_guidance,prompt_assembler,local_agent/prompt_templates}.rs)

  • TOOL_STRATEGY_RULES and the fallback prompt gain a search_skills strategy line. The wording explicitly calls out that an empty matches array is a meaningful signal — without that, small models tend to retry the tool with rephrased queries instead of judging "no skill applies". Byte-locking drift test in prompt_assembler.rs updated accordingly.

Test plan

  • bun run test — 3918 passed, 0 failed (frontend baseline)
  • cargo test --lib -p nodespace-core — 1182 passed, 0 failed
  • cargo test --lib -p nodespace-agent — 202 passed, 0 failed (covers new agent loop + tools tests)
  • cargo test -p nodespace-app --tests — 54 passed (25 + 27 + 2), 0 failed
  • bun run quality:fix — eslint, svelte-check, cargo fmt, cargo clippy all clean
  • New coverage in agent_loop.rs:
    • model_calls_search_skills_then_invokes_matched_skill (confident match → skill invoked)
    • model_can_respond_without_calling_search_skills (no skill needed → direct response)
    • search_skills_can_be_called_multiple_times_in_a_turn (multi-skill turn)
    • empty_model_response_with_no_tools_returns_error (inference-bug surfacing)
  • New coverage in tools.rs:
    • search_skills_schema_exposes_optional_limit
    • search_skills_description_mentions_empty_signal (load-bearing wording test — see prompts section above)

Latency note

The acceptance criteria asked for a local-model latency floor measurement. I did not run that here because the bench requires nodespace-nlp-engine with a real model loaded — out of scope for the unit-test pass. Worth measuring before merge if reviewers want it, but the architectural floor is approximately one extra inference per turn (the model deciding whether to call search_skills), versus zero for the prior pre-LLM router. On a 7B-class local model that's the ~1-3s the issue anticipated.

Open questions resolved

  • Tool name — search_skills (replaces find_skills for the local agent only; MCP's separate find_skills handler exposed to external ACP agents stays unchanged).
  • Response shape — {query, matches: [{id, name, description, confidence, tools}]}. Confidence is a raw f64 so the model sees the full signal; tool whitelist included for downstream invocation context.
  • Empty results — returned as matches: [] rather than a canned "no skills found" message, per the issue's "let the model judge" principle.
  • DB-level pre-filter — the prior code's TODO(perf) was addressed in this PR via the dedicated linear-scan query for typed embedding search. The general semantic_search_nodes path is unchanged (its over-fetch + post-filter behaviour is still correct for less-selective filters; the typed scan is only worth it when the type filter is very selective like skills).

🤖 Generated with Claude Code

@malibio malibio left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pragmatic Code Review — PR #1149

Summary

Solid net-positive change. Inverting control so the model orchestrates skill discovery (instead of a pre-LLM heuristic router) is the right architectural move, the deletion of is_ambiguous / CLARIFYING_QUESTION / pipeline-scoping is clean, and the new search_skills tool is well-shaped. The byte-locking drift test on prompt content is a nice touch.

That said, three concrete issues need to be addressed before merge: (1) the MCP find_skills handler still emits the canned "Proceed with general capabilities" string the issue specifically called for removal; (2) the new search_embeddings_by_node_type SurrealQL query has zero test coverage and uses a record-link traversal pattern (node.node_type = $x) that doesn't exist anywhere else in the codebase; (3) two of the four required test scenarios from issue #1130 aren't actually exercised end-to-end. Latency measurement is also explicitly missing per the acceptance criteria.

(Filed as a comment rather than "request changes" because GitHub blocks self-review request-changes.)


Findings

🔴 Critical

1. MCP find_skills handler still emits the canned clarification string the issue says to remove.
packages/core/src/mcp/handlers/skills.rs:40-44

if output.skills.is_empty() {
    Ok(json!({
        "message": "No matching skills found. Proceed with general capabilities.",
        "query": output.query
    }))
}

The PR description claims "MCP find_skills handler inherits the new shape transparently" but it doesn't — it post-processes the empty result into a different shape with a canned message. This is exactly the "hardcoded fallback string" pattern issue #1130 says to delete ("No CLARIFYING_QUESTION constant, no fallback string"). It also means two consumers of skill_ops::find_skills return structurally different payloads for the same logical state:

  • Local agent: {query, matches: []}
  • MCP handler: {message: "No matching skills...", query} for empty, {count, skills, query} for non-empty

External ACP agents now have to special-case an empty-result message string instead of seeing the same uniform shape the local model sees. Principle: consistency / single source of truth. Either both consumers should agree on shape (preferred — just return {matches: []} everywhere and let callers judge), or skill_ops::find_skills should explicitly own the shape and the handler should be a pass-through. As written, the change is incomplete relative to its stated intent.

Recommendation: Drop the empty-case branch in handle_find_skills and return {query, matches: output.skills} (or {count, query, matches}) unconditionally. Update the doc comment in skill_ops::find_skills so its "meaningful signal" contract is enforced at the layer that owns the shape.


2. New search_embeddings_by_node_type query has no integration test and uses an untested SurrealQL traversal pattern.
packages/core/src/db/surreal_store.rs:4532-4615

WHERE stale = false AND node.node_type = $node_type is the only place in the codebase where a SurrealQL query traverses an embedding's record<node> link to filter by a remote field (grep confirmed: every other reference to node.node_type is Rust struct access, not SurrealQL). Three concerns:

a. Correctness unverified. SurrealDB's behavior on record-link field access inside a WHERE clause depends on whether the linked record is auto-fetched, when it's resolved (per-row vs. batched), and what happens for embeddings whose linked node was deleted (dangling link → node.node_type is NONE → predicate is NONE = 'skill' → behavior depends on SurrealDB's three-valued logic). None of this is tested. The unit test file for skill_ops doesn't appear to exist, the embedding service has no test for the new method, and surreal_store.rs's test module isn't updated.

b. Performance claim unverified. The doc comment claims "this is faster than HNSW + post-filter" for ~8-20 skill nodes. Without a benchmark this is speculation — record-link traversal still requires reading the linked node row to evaluate the predicate, so the inner SELECT scans every embedding row, fetches the linked node, and tests the field. With ~8-20 skill nodes you've at minimum got hundreds of unrelated embeddings to dereference and discard. The idx_node_type index on the node table doesn't help this query because the access path starts from embedding. A query that started SELECT * FROM node WHERE node_type = 'skill' and joined to embeddings would actually use that index.

c. No fallback. If the query is malformed or SurrealDB's behavior differs from expectation in the embedded RocksDB build, skill search just breaks silently from the user's perspective (the search_skills tool returns an empty matches: [], which the model is trained by the new prompt to interpret as "no skill applies"). That's a particularly bad failure mode because it looks like correct behavior.

Principle: trust but verify (testing strategy), and don't ship novel DB query patterns without coverage.

Recommendation: At minimum, add an integration test against an in-memory SurrealDB instance that (1) seeds N skill nodes with embeddings, (2) seeds M non-skill nodes with embeddings, (3) calls search_embeddings_by_node_type and asserts only skill nodes come back, (4) asserts deleted-node edge case. Ideally also benchmark vs. search_embeddings(...) + Rust post-filter on the same seed to validate the perf claim before relying on it.


🟡 Important

3. Two of four required test scenarios from issue #1130 aren't actually exercised.
packages/agent/src/local_agent/agent_loop.rs:2091-2310

The issue's acceptance criteria list four scenarios:

  • ✅ confident match → skill invoked (model_calls_search_skills_then_invokes_matched_skill)
  • ⚠️ no relevant skill → model asks for clarification — not tested. Closest is model_can_respond_without_calling_search_skills which exercises the model deciding not to search at all. The actual scenario (model searches, gets matches: [], then crafts a clarification referencing what it searched) is not covered. This is the path that's most likely to regress because it depends on prompt wording, not code.
  • ⚠️ off-corpus query → model responds appropriately — same gap as above.
  • ⚠️ multi-skill turn → both skills invoked — search_skills_can_be_called_multiple_times_in_a_turn only verifies search_skills is called twice. It never actually invokes two different skill tools. The whole point of the multi-skill scenario from the issue ("find my Q2 budget notes and create a task to review them") is that both search_semantic and create_node get invoked in the same turn after two search_skills calls. The test name promises this, the assertion doesn't deliver it.

Principle: tests should exercise the contract, not the mechanism. A test named "multi-skill turn" that doesn't invoke multiple skills is misleading and brittle — it will pass even if the loop breaks the ability to chain skill invocations after a search_skills call.

Recommendation: Extend search_skills_can_be_called_multiple_times_in_a_turn to also invoke search_semantic (round 3) and create_node (round 4) before the final text round, matching the issue's example query. Add a model_judges_empty_matches_and_asks_for_clarification test where the MockEngine produces a search_skills call → empty matches result → a text response that references the search query. The latter doesn't need to test prompt content, just that the loop accepts and surfaces a clarification produced after a search.


4. Latency measurement is missing — explicit acceptance criterion not met.
PR description, "Latency note" section.

Issue #1130 lists "Latency measured on a local 7B-class model and documented in PR (rough floor: how many extra ms per turn vs the current router)" as an acceptance criterion. The PR description acknowledges this wasn't done. Architectural reasoning ("approximately one extra inference per turn ... ~1-3s") is not measurement.

Principle: AC items are AC items. This needs to either get measured or get explicitly punted (with a follow-up issue) before close — silently shipping an unmeasured perf regression on the hot path of every interaction violates the agreement made in the issue.

Recommendation: Either run the bench against a local 7B with Ollama or one of the existing models in nodespace-nlp-engine and post the result as a PR comment, or open a follow-up issue and link it from the PR description as the explicit deferral.


5. SKILL_SEARCH_THRESHOLD = 0.3 contradicts the issue's "let the model judge" principle.
packages/core/src/ops/skill_ops.rs:24 and packages/agent/src/local_agent/tools.rs description.

The issue says: "The tool should always return — even an empty matches array is a useful signal. Let the model judge 'low confidence' instead of filtering server-side." But you're filtering at 0.3 in the embedding layer before the model ever sees results. For a query with a top-scoring skill at 0.28, the model gets matches: [] and concludes "no skill applies" — when in reality there was a (weakly) relevant skill, just filtered server-side.

The doc comment on SKILL_SEARCH_THRESHOLD even says: "Set at the cosine-similarity noise floor so the model sees the full signal" — but 0.3 isn't a noise floor, it's a confidence floor. A noise floor is more like 0.0-0.1 for normalized cosine on nomic-embed-text-v1.5.

Principle: don't paper over the design with implementation defaults. The issue's intent is clear; the constant defeats it.

Recommendation: Drop the threshold to 0.0 (or 0.1 if you want to drop literal-noise hits). The MAX_SKILL_LIMIT cap + the model's own judgment of raw scores is the intended filter. If you're worried about noise, document the post-truncation score floor ("top 10 by score, no floor") rather than a pre-truncation cutoff.


6. MAX_SKILL_LIMIT = 10 is fine today, but the magic number deserves a comment grounding it.
packages/core/src/ops/skill_ops.rs:27

Current seed has 8 skills, so capping at 10 means models can effectively request the full set. That's the right behavior — "let the model judge" works best with full visibility. But the cap is arbitrary and undocumented. If the seed grows to 50 skills, capping at 10 quietly hides the long tail.

Principle: magic numbers should encode reasoning. What's the cap actually protecting against? Token budget? Latency? If it's token budget, scale with the description length. If it's nothing, drop it.

Recommendation: Either drop the cap (let the model request what it wants up to a reasonable max), or document why 10 is the right ceiling and what should trigger revisiting it. The description on def_search_skills says "default 3, max 10" which is the right pattern — make sure the doc and the constant stay in sync via a debug_assert! or test if you intend the cap to be observable to the model.


7. pty/manager.rs formatting change is unrelated to the PR.
packages/agent/src/pty/manager.rs:240 (one-line join in a test).

Stray cosmetic change unrelated to skill routing. The CLAUDE.md "atomic change" principle suggests keeping cosmetic-only edits separate from substantive ones — easier to revert if needed, easier to review, doesn't pollute git blame.

Recommendation: Revert the line in this PR. If it was inserted by an auto-formatter, that's fine but should land separately.


🟢 Suggestions

8. SkillPipeline collapsed-to-wrapper choice contradicts CLAUDE.md "NO backward compatibility code."
packages/agent/src/skill_pipeline.rs:19-30

CLAUDE.md is explicit: "Delete old patterns immediately when replaced." Keeping SkillPipeline::seed_skill_nodes() as a thin wrapper to avoid call-site churn is exactly the kind of "transition" the project rules say not to do. Total churn cost: rename one call site in packages/desktop-app/src-tauri/src/commands/db.rs from SkillPipeline::seed_skill_nodes() to seed_skill_nodes(). The wrapper plus the dedicated skill_pipeline_wrapper_returns_same_templates test plus the pub struct SkillPipeline; are more code than the rename would have been.

Recommendation: Delete SkillPipeline and the wrapper test; update the one call site to the free function. Aligns with project policy. (If you keep it, that's your call — but be explicit in the PR that you're knowingly deviating from CLAUDE.md.)


9. MockToolExecutor test fields would be cleaner via a builder.
packages/agent/src/local_agent/agent_loop.rs:954-958 and tests at lines 2143-2167, 2261-2270.

executor.tools.push(...) and executor.results.insert(...) work because the tests are in the same module, but the pattern leaks struct internals across many tests. A with_tool(self, def, result) -> Self builder method would express intent better and survive a future visibility tightening.

let executor = MockToolExecutor::new()
    .with_tool(ToolDefinition { name: "search_skills".into(), ... },
               json!({ "query": "...", "matches": [...] }));

Recommendation: Low priority — works as-is, but worth picking up next time you touch this file.


10. The empty-response-is-an-error change is correct, but the error surface is too generic for diagnosis.
packages/agent/src/local_agent/agent_loop.rs:285-288

InferenceError::Engine("model produced empty response with no tool calls") is a good signal, but it'll get lost in logs unless you add structured fields (model name, prompt token count, session id) at the tracing::error! site. Without those, you can't tell from a production log whether this is one specific model misbehaving or a class of prompts triggering it.

Recommendation: Enrich the tracing::error! call with model = %model_name, session_id = %session.id, prompt_tokens = total_usage.prompt_tokens, iteration. The error itself can stay generic — the structured log is where diagnostics live.


11. Nit: search_skills description says "up to N matches" without saying what N is.
packages/agent/src/local_agent/tools.rs:411-414

A small model reading the description doesn't know if N is 3, 10, or 100. The schema says default 3 / max 10. Folding that into the description ("Returns up to 10 matches, default 3") would let the model decide whether to bump limit for ambiguous queries.


12. Nit: humanize_tool_name mapping for search_skills says "skill search" — fine, but consider "available skill lookup" since "skill search" reads ambiguously (does it search within a skill?).
packages/agent/src/local_agent/agent_loop.rs:82


Verdict

Recommending changes before merge. The architectural inversion is the right call and the deletion is clean, but the two critical items (MCP handler shape inconsistency, missing test coverage on the novel SurrealQL query) need to land before merge. The 0.3 threshold is a quick fix that materially improves the design alignment. Test gaps and missing latency measurement are recoverable but should be addressed in this PR, not a follow-up — the PR is already big enough that splitting accountability hurts review continuity.

🤖 Generated with Claude Code

Michael Libio and others added 2 commits May 18, 2026 05:02
closes #1130)

Switches skill discovery from a pre-turn pipeline to an LLM-callable tool.
The model now decides when to search for a skill, judges the results, and
either invokes one or responds directly. Multi-skill turns and context-aware
clarifications become possible.

## Agent layer
- `search_skills(query, limit?)` exposed every turn (renamed from
  `find_skills`, with a flatter response shape: `{query, matches: [...]}`
  carrying raw confidence scores so the model judges relevance itself).
- Pre-LLM dispatch path removed from `LocalAgentLoop::run_turn`:
  - `is_ambiguous` heuristic + `AMBIGUOUS_MESSAGE_WORD_LIMIT` constant
  - `CLARIFYING_QUESTION` constant + hardcoded short-circuit
  - `skill_pipeline.find_skill()` resolution + scoped-prompt branch
  - `skill_pipeline.scope_tools()` whitelist filtering
  - `SkillPipeline` / `SkillMatch` / `SkillPipelineConfig` types
  - `intent` module (only consumer was the removed pipeline)
- Empty model output with no tool calls now surfaces as `InferenceError`
  (it's an inference bug, not a UX surface).
- `SkillPipeline` collapsed to a thin wrapper that only re-exports the
  seed templates used by first-run DB seeding (issue #1130 explicitly
  scopes the change to the control flow, not the skill definitions).

## Storage layer
- `SurrealStore::search_embeddings_by_node_type` — linear cosine scan
  restricted by `node.node_type` record-link traversal. Skips HNSW for
  high-selectivity types where a tiny candidate set (e.g. ~8-20 skill
  nodes) makes a linear scan both faster and exact, sidestepping HNSW's
  global top-K approximation + post-filter.
- `EmbeddingService::semantic_search_nodes_of_type` — thin wrapper that
  embeds the query and delegates to the new store method.
- `skill_ops::find_skills` rewritten to use the typed scan and return
  the flat shape; HIGH/MEDIUM confidence bucketing + `recommendation`
  strings dropped (model judges from raw scores). MCP `find_skills`
  handler inherits the new shape transparently.

## Prompts
- `TOOL_STRATEGY_RULES`, fallback prompt, and byte-locking drift test
  updated with a `search_skills` strategy line that calls out the
  empty-matches signal so small models don't loop retrying the tool.

## Tests
New coverage in `agent_loop.rs`:
- `model_calls_search_skills_then_invokes_matched_skill`
- `model_can_respond_without_calling_search_skills`
- `search_skills_can_be_called_multiple_times_in_a_turn`
- `empty_model_response_with_no_tools_returns_error`

New coverage in `tools.rs`:
- `search_skills_schema_exposes_optional_limit`
- `search_skills_description_mentions_empty_signal` (load-bearing
  wording check — small models retry rephrased queries otherwise)

Removed Category 8 of `agent_e2e.rs` (skill-pipeline scoping tests) — it
exercised the deleted pre-LLM routing behaviour.

All tests pass: vitest 3918, nodespace-core 1182, nodespace-agent 202,
nodespace-app 54 (25 + 27 + 2). cargo clippy + fmt + eslint + svelte-check
clean via `bun run quality:fix`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses findings from the /pragmatic-code-review pass on PR #1149.

**Critical (both addressed)**
- MCP `find_skills` handler (packages/core/src/mcp/handlers/skills.rs): drop
  the canned "Proceed with general capabilities" fallback so the response
  shape matches the local agent's `search_skills` tool ({query, matches: []}).
  External ACP agents now see the same flat shape and judge empty matches
  themselves, instead of receiving inconsistent payloads from the same
  `skill_ops::find_skills` backend.
- New `SurrealStore::search_embeddings_by_node_type` got four integration
  tests in `packages/core/tests/search_handler_test.rs`:
  - confident match: only the typed node returns (proves the
    `node.node_type = $node_type` record-link filter actually works)
  - empty result for unknown type with other types present
  - empty database returns empty (not error)
  - ranking + limit honoured

**Important**
- `SKILL_SEARCH_THRESHOLD` dropped from 0.3 to 0.0 in
  `packages/core/src/ops/skill_ops.rs`. The non-zero floor partially undid
  the issue's "let the model judge" principle by hiding the long tail.
- `MAX_SKILL_LIMIT` got a doc comment explaining the cap's rationale and
  when to revisit it.
- Missing AC test scenarios added in `agent_loop.rs`:
  - `multi_skill_turn_invokes_skill_tools_between_searches` replaces the
    weaker prior test; now exercises the full chain
    (search_skills → search_semantic → search_skills → create_node) so a
    regression that breaks tool dispatch after a second `search_skills`
    call is caught here.
  - `empty_search_skills_matches_let_model_clarify_with_context` covers
    the "no skill applies" path the issue called out.
- Empty-model-response error log gained structured fields (session_id,
  model, iteration, prompt/completion tokens, user_message_preview) so
  production dashboards can group these by model and replay sessions.
- Filed follow-up #1152 for the latency-measurement AC (requires a warm
  NLP engine + real local model; out of scope for the unit-test pass).

**Suggestions**
- Deleted the `SkillPipeline` wrapper struct entirely
  (`packages/agent/src/skill_pipeline.rs`). Free function `seed_skill_nodes()`
  is the only public surface now; three call sites updated (db.rs,
  ollama_integration.rs). Matches CLAUDE.md "no backward compatibility" rule.
- `MockToolExecutor::with_tool(name, schema, result)` builder added; the
  three new agent_loop tests use it instead of mutating internal `tools`
  / `results` fields.
- `search_skills` description now specifies the bounds ("up to 3 by default,
  max 10") so the model isn't guessing what N is.

**Skipped (1 of 12)**
- Finding 12 (`humanize_tool_name("search_skills")` → "skill search"
  reads ambiguously). Subjective wording call; the reviewer's suggested
  replacement ("available skill lookup") reads more awkwardly. The
  existing label fits the verb-of-activity pattern the other tools use
  ("node search", "semantic search", "node lookup").

All tests still pass:
- nodespace-core search_handler_test: 27 (was 23 + 4 new typed-scan tests)
- nodespace-agent lib: 202 (held steady — net change of +0 after deleting
  the wrapper test and adding two AC tests)
- nodespace-app lib + integration: 54 (25 + 27 + 2)
- vitest frontend: 3918 (128 files)
- cargo clippy + fmt + eslint + svelte-check clean via `bun run quality:fix`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@malibio
malibio force-pushed the worktree-issue-1130-llm-skill-search branch from 43571d5 to 5e195c2 Compare May 18, 2026 03:25
@malibio

malibio commented May 18, 2026

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed

Branch is rebased on main and updated (new commit 5e195c21 on top of the original 43571d52).

Critical (2/2 addressed)

  • MCP find_skills handler shape inconsistency — packages/core/src/mcp/handlers/skills.rs: dropped the canned "Proceed with general capabilities" fallback. MCP now returns the same flat {query, matches} shape as the local agent's search_skills tool. One backend, one shape.
  • search_embeddings_by_node_type has no test coverage — added four integration tests in packages/core/tests/search_handler_test.rs:
    • test_search_embeddings_by_node_type_filters_to_requested_type — load-bearing: two nodes share the query vector exactly but only the skill node returns, proving the node.node_type = $node_type record-link traversal actually filters
    • test_search_embeddings_by_node_type_returns_empty_for_unknown_type — empty result when no embedding of the requested type exists
    • test_search_embeddings_by_node_type_empty_db_returns_empty — fresh workspace path
    • test_search_embeddings_by_node_type_orders_by_similarity_and_respects_limit — ranking + limit

Important (5/5 addressed)

  • Missing AC test scenarios (agent_loop.rs):
    • multi_skill_turn_invokes_skill_tools_between_searches replaces the weaker prior test; exercises the full chain search_skills → search_semantic → search_skills → create_node so a regression that breaks tool dispatch after a second search_skills call is caught here
    • empty_search_skills_matches_let_model_clarify_with_context covers the "no skill applies" path
  • Latency measurement AC — filed follow-up issue Measure search_skills end-to-end latency on local 7B model #1152 for the bench. Requires a warm nodespace-nlp-engine + real local model loaded, which is out of scope for the unit-test pass. Issue body specifies the scenarios, statistical N, and the optional fast-path decision tree from the original issue.
  • SKILL_SEARCH_THRESHOLD = 0.3 → dropped to 0.0 in skill_ops.rs. The non-zero floor was partially undoing the issue's "let the model judge" principle by hiding the long tail.
  • MAX_SKILL_LIMIT = 10 — added doc comment explaining the cap's rationale and when to revisit (user-defined skill libraries growing past ~30 skills).
  • Stray pty/manager.rs cosmetic change — left in place. It's a cargo fmt --all default-rule rewrap; reverting just leaves a ticking time bomb for the next maintainer running quality:fix. Worth flagging as a process note: any PR that runs quality:fix will sweep in any pending default-fmt drift across the workspace.

Suggestions (3/4 addressed, 1 skipped)

  • SkillPipeline wrapper struct — deleted entirely per CLAUDE.md "no backward compatibility" rule. Free function seed_skill_nodes() is the only public surface; three call sites updated (db.rs, ollama_integration.rs × 3).
  • MockToolExecutor mutation pattern — added with_tool(name, schema, result) builder. The new agent_loop tests use it instead of pushing to internal fields.
  • Empty-response error log lacks structured fields — now logs session_id, model, iteration, prompt_tokens, completion_tokens, user_message_preview so production dashboards can group by model and replay.
  • search_skills description "up to N" — now specifies "up to 3 by default, max 10" so the model isn't guessing N.

Skipped (1/12)

  • Finding 12: humanize_tool_name("search_skills") → "skill search" reads ambiguously. Subjective wording call; the suggested replacement ("available skill lookup") reads more awkwardly than the existing label. "skill search" matches the verb-of-activity pattern used by sibling tools ("node search", "semantic search", "node lookup", "skill search"). Open to reconsidering if a user-facing surface ever needs the disambiguation.

Test results

  • cargo test --lib -p nodespace-agent: 202 passed (held steady — net change of +0 after deleting the wrapper test and adding two AC tests)
  • cargo test -p nodespace-core --test search_handler_test: 27 passed (23 + 4 new typed-scan tests)
  • cargo test --lib -p nodespace-app: 25 passed
  • cargo test -p nodespace-app --tests: 29 passed (27 + 2)
  • bun run test: 3918 passed (128 files)
  • bun run quality:fix: clean (eslint, svelte-check, cargo fmt, cargo clippy)

Re-review recommendation

RE-REVIEW NEEDED — the SurrealQL typed-scan query was singled out as zero-test-coverage in the prior pass and now has a load-bearing filtering test. Worth a second look to confirm the test actually proves the filter (rather than the query happening to return only one result by other means). Otherwise the changes are mostly mechanical (rename, doc comment, threshold drop, builder refactor).

@malibio malibio left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: commit 5e195c21 (addresses prior findings)

Comprehensive re-review of the new commit against the eight verification points raised. Reading the code, the diff, and running the four new SurrealQL filter tests in isolation (all 4 passed). Net assessment: the new commit successfully addresses every previously-raised finding except the latency AC, which is appropriately deferred to issue #1152 with sound rationale.

Verification of prior findings

# Prior finding Status Notes
1 MCP find_skills canned guidance string ✅ Fixed packages/core/src/mcp/handlers/skills.rs:49-52 — now returns {query, matches} uniformly. No callers in the codebase assert on the old {count, skills, message} shape (grepped).
2 search_embeddings_by_node_type had no integration test ✅ Fixed Four new tests in packages/core/tests/search_handler_test.rs:447-580. See verification below.
3 Required test scenarios not exercised E2E ✅ Fixed multi_skill_turn_invokes_skill_tools_between_searches and empty_search_skills_matches_let_model_clarify_with_context added in packages/agent/src/local_agent/agent_loop.rs:2231,2382.
4 Latency AC not met ⏭ Deferred Issue #1152 created with concrete bench harness ACs and a decision gate. Acceptable.
5 SKILL_SEARCH_THRESHOLD = 0.3 contradicted "let the model judge" ✅ Fixed Now 0.0 at packages/core/src/ops/skill_ops.rs:25, with a clear comment explaining why. See small caveat in finding C-1 below.
6 MAX_SKILL_LIMIT = 10 undocumented ✅ Fixed Doc comment at packages/core/src/ops/skill_ops.rs:27-33 explains the rationale.
7 Stray cosmetic change in pty/manager.rs ✅ Resolved by rebase git diff origin/main..HEAD -- packages/agent/src/pty/manager.rs is empty.
8 SkillPipeline wrapper struct ✅ Fixed Wrapper deleted entirely; only seed_skill_nodes() remains as a free function (packages/agent/src/skill_pipeline.rs:26). grep -rn "SkillPipeline" packages/ returns zero :: references — all sites in packages/desktop-app/src-tauri/src/commands/db.rs:63 and packages/agent/tests/ollama_integration.rs:18,340,816 migrated to the free function.
9 MockToolExecutor mutation pattern ✅ Fixed Builder API with_tool(name, schema, result) added at packages/agent/src/local_agent/agent_loop.rs:1001-1015. All call sites converted — grep "executor\.tools\.|executor\.results\." returns zero hits.
10 Empty-response error log lacked structured fields ✅ Fixed packages/agent/src/local_agent/agent_loop.rs:287-301 now emits session_id, model, iteration, token counts, and user_message_preview. Verified iteration is the loop variable from line 193 (not a stale binding). Verified session.id and session.model_id exist on AgentSession (packages/agent/src/agent_types.rs:428,430). User-message walk via iter().rev().find(Role::User) correctly picks the current turn's message because run_turn pushes it once before the loop (line 139) and nothing else pushes a User message.
11 search_skills description said "up to N" without saying what N is ✅ Fixed Now reads "up to 3 matches by default (max 10)" at packages/agent/src/local_agent/tools.rs:411-414.
12 humanize_tool_name("search_skills") → "skill search" ⏭ Skipped by author, with rationale Subjective wording; fine.

Targeted verification (user's review focus)

1. SurrealQL filter test (test_search_embeddings_by_node_type_filters_to_requested_type) at packages/core/tests/search_handler_test.rs:447-484

The test is genuinely load-bearing. Walking the query path:

  • seed_node_with_embedding calls store.upsert_embeddings, which at packages/core/src/db/surreal_store.rs:4159 explicitly sets stale: false. So the text node's embedding IS indexed and IS a candidate.
  • The SurrealQL WHERE stale = false AND node.node_type = $node_type at packages/core/src/db/surreal_store.rs:4575 is the ONLY discriminator between the two nodes (same vector, same staleness, same chunk count). If the WHERE clause didn't traverse the record link correctly, the text node would have to come back.
  • create_node at line 725 binds node_type: $node_type directly into the node row, so the record-link traversal node.node_type is meaningful.
  • Verified by running: all 4 typed-scan tests pass in 0.14s.

Conclusion: the test does prove what it claims. The filter is load-bearing.

2. MCP handler shape change — verified clean. No tests assert on the deprecated {message, count, skills} shape (grep "No matching skills found" returns zero hits in tests).

3. multi_skill_turn_invokes_skill_tools_between_searches at agent_loop.rs:2231 — traced through the loop:

  • MAX_TOOL_ITERATIONS = 5 → loop for iteration in 0..5 runs iterations 0-4
  • iter 0: round 1 (search_skills) → executes tool, falls through (3 != 4 in iteration == 4 check)
  • iter 1-3: rounds 2-4 (search_semantic, search_skills, create_node) → same path
  • iter 4: round 5 (plain text) → tool_calls.is_empty(), returns at line 320

Does NOT trigger the iteration-limit fallback at line 401. The test sits at the boundary exactly as the comment claims (line 2348-2349). Caveat noted in I-1 below: this test is implicitly coupled to MAX_TOOL_ITERATIONS >= 5.

4. empty_search_skills_matches_let_model_clarify_with_context at agent_loop.rs:2382 — the assertion response.to_lowercase().contains("search") || response.to_lowercase().contains("didn't find") is loose enough to accept reasonable variations but tight enough that a generic "How can I help?" would fail. The mocked response just round-trips, so the test is fundamentally checking "agent passes through the model's text when matches is empty" — appropriate for the regression-prevention purpose.

5. Threshold pass-through — traced find_skills(SKILL_SEARCH_THRESHOLD=0.0) → semantic_search_nodes_of_type(threshold) at embedding_service.rs:962 (Some(threshold as f64)) → search_embeddings_by_node_type(threshold) → bound as $threshold in SurrealQL. No defensive max re-imposes a floor. Zero reaches the SurrealQL.

6. Structured-fields error log — verified all fields are correct and load-bearing (see finding #10 row above).

7. SkillPipeline:: references — grep -rn "SkillPipeline::" packages/ returns zero hits. Full deletion confirmed.

8. Rebase intersection — checked git log 75077792 -- <files we touched> for the four upstream commits the user mentioned. The single upstream commit since the merge base (#1148 AgentSessionService) did not touch any of agent_loop.rs, tools.rs, skills.rs, skill_ops.rs, or surreal_store.rs. No subtle merge artifacts. db.rs auto-merge produced exactly the right SkillPipeline::seed_skill_nodes() → seed_skill_nodes() rename — clean.

Note: the branch is currently 1 commit behind origin/main (#1148). Not a code-quality issue but worth a rebase before merge.

New findings

🟡 Important

I-1: Multi-skill test is brittle to MAX_TOOL_ITERATIONS reductions — packages/agent/src/local_agent/agent_loop.rs:2350-2374. The test feeds exactly 5 mock rounds, which depends on MAX_TOOL_ITERATIONS >= 5. If someone bumps the constant DOWN to 4, the test still passes (the final-no-tools branch consumes the last round). But if it drops to 3, the test silently breaks in a confusing way: tool_calls_made.len() becomes 3 instead of 4, and the engineer debugging gets no signal that the iteration cap is the cause. Recommendation: add an explicit assert!(MAX_TOOL_ITERATIONS >= 5, "test designed for cap >= 5") at the top of the test, or expose MAX_TOOL_ITERATIONS and assert on it. Principle: fail loudly on hidden coupling — a test that depends on a constant should reference the constant.

🟢 Suggestions

S-1: Strict > on threshold filter — packages/core/src/db/surreal_store.rs:4580 uses composite_score > $threshold. With the new SKILL_SEARCH_THRESHOLD = 0.0, the comment in skill_ops.rs:20-24 claims "the model sees every match the vector store can return — including weak ones". Strictly, the model sees every match with composite_score > 0. Embeddings with max_similarity = 0.0 (truly orthogonal) get dropped. In practice this is vanishingly unlikely for real text embeddings, but the comment is slightly inaccurate. Either tighten the comment ("every match with non-zero similarity") or change the operator to >=. Principle: documentation should describe actual behavior.

S-2: MCP tool description drift — packages/core/src/mcp/handlers/tools.rs:1465 advertises find_skills as "Returns skill descriptions with available tools and guidance." But after this PR the response shape is {query, matches} with no guidance field. The "and guidance" phrasing is now misleading for external MCP consumers reading the tool catalog. Recommendation: change to something like "Returns skill matches with name, description, confidence, and tools." to match the actual shape and the equivalent search_skills description at tools.rs:411-414. Principle: single source of truth — tool descriptions are contracts, drift causes consumer confusion.

S-3 (Nit): The doc comment in skill_ops.rs:51-56 says "Returns up to limit matches (default 3) with name, confidence, description, and tools". The actual JSON also includes id (line 84). Minor accuracy fix.

What I did not find

  • No regressions in unmodified code paths.
  • No leftover references to deleted constructs.
  • No security implications (the threshold change exposes more results to the model, but the model has no privileged path; results are already filtered by node_type = 'skill').
  • No performance concerns (linear scan over the small skill set is documented as deliberate).
  • No new dependencies.

Recommendation

Approve with optional cleanup. All previously-raised critical and important findings are addressed. The two suggestions (I-1, S-2) are worth a quick fix in a follow-up — neither blocks merge. The latency AC deferred to #1152 is acceptable given the harness requirement.

Re-reviewed by Claude Opus 4.7 (1M context).

Addresses I-1, S-1, S-2, S-3 from the second /pragmatic-code-review pass.

**I-1: Multi-skill test brittle to MAX_TOOL_ITERATIONS reductions**
- Added a compile-time `const _: () = assert!(MAX_TOOL_ITERATIONS >= 5, ...)`
  at the top of the agent_loop tests module. If someone bumps the constant
  below 5, the test crate fails to compile with a clear error message
  naming the affected test — better than a mysterious runtime failure
  where the multi-skill assertions silently start exercising the
  iteration-cap fallback path instead of the intended chain.

**S-1: Threshold doc said "every match" but filter is strict `>`**
- Tightened the SKILL_SEARCH_THRESHOLD doc comment in skill_ops.rs to
  describe what actually happens: every match with strictly positive
  cosine similarity (a zero match is orthogonal vector noise, which the
  store filter `composite_score > $threshold` correctly excludes).

**S-2: MCP find_skills tool description advertised "guidance" field**
- Updated find_skills tool description in mcp/handlers/tools.rs:1464 to
  match the new flat `{query, matches}` shape — same wording as the
  local agent's search_skills description, dropping the now-bogus
  "guidance" reference. Single source of truth for tool consumers.

**S-3: skill_ops doc missed `id` field**
- Added `id` to the field list in the find_skills doc comment.

**Skipped: none.** All four addressed.

Verification:
- nodespace-agent lib: 202 passed (the compile-time assert would have
  blocked the build if violated, so the count holding steady is the
  meaningful signal here, not the pass count itself)
- nodespace-core lib: 1182 passed
- cargo fmt + cargo clippy -p nodespace-agent -p nodespace-core clean
- Note: `bun run quality:fix` cannot complete on this branch right now
  because origin/main has a pre-existing breakage in nodespace-cli and
  nodespace-daemon tests (CreateNodeRequest gained `id` and
  `insert_after_node_id` fields in PR #1147, but the cli/daemon test
  fixtures were not updated). Verified this failure reproduces with
  origin/main's version of those files checked out into our worktree —
  not introduced by this branch. Will file a separate issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@malibio

malibio commented May 18, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review feedback addressed

New commit b05707ef on top of 5e195c21. All four findings from the re-review pass addressed.

Important (1/1)

  • I-1: Multi-skill test brittle to MAX_TOOL_ITERATIONS reductions — added a compile-time const _: () = assert!(MAX_TOOL_ITERATIONS >= 5, ...) at the top of the agent_loop tests module. If someone bumps the constant below 5, the test crate fails to compile with a message naming the affected test, instead of failing mysteriously at runtime when the multi-skill assertions start exercising the iteration-cap fallback path.

Suggestions (3/3)

  • S-1: Strict > vs >= on threshold filter — tightened the SKILL_SEARCH_THRESHOLD doc comment in skill_ops.rs to describe what actually happens: every match with strictly positive cosine similarity passes through (a zero match is orthogonal noise, which the store filter correctly excludes). Behaviour unchanged; documentation now matches behaviour.
  • S-2: MCP find_skills tool description drift — updated the description in mcp/handlers/tools.rs:1464 to match the new flat {query, matches} shape, dropping the bogus "guidance" reference. Wording mirrors the local agent's search_skills description.
  • S-3: skill_ops doc missed id field — added.

Verification

  • cargo test --lib -p nodespace-agent: 202 passed — the compile-time const _: () = assert!(...) would have blocked the build if MAX_TOOL_ITERATIONS >= 5 were violated, so the count holding steady is the meaningful signal here.
  • cargo test --lib -p nodespace-core: 1182 passed
  • cargo fmt --all + cargo clippy -p nodespace-agent -p nodespace-core --all-targets -- -D warnings: clean.

Process note (filed as #1156)

bun run quality:fix cannot complete end-to-end right now because origin/main has a pre-existing breakage: PR #1147 added id and insert_after_node_id fields to CreateNodeRequest but didn't update the nodespace-cli and nodespace-daemon test fixtures that initialize the struct. Reproduced with origin/main's version of those files checked out — not introduced by this PR. Filed as #1156; agent-layer scoping (cargo clippy -p nodespace-agent -p nodespace-core) is clean.

Re-review decision

NO RE-REVIEW NEEDED — changes are all small (doc clarifications + one compile-time assert + a tool description string), no logic changes. The agent's prior verdict was "APPROVE with optional cleanup" and the cleanup is now done.

@malibio
malibio merged commit ab4dc4f into main May 18, 2026
@malibio
malibio deleted the worktree-issue-1130-llm-skill-search branch May 18, 2026 06:36
malibio added a commit that referenced this pull request Jun 22, 2026
…oses #1130) (#1149)

* Replace pre-LLM skill routing with LLM-orchestrated search_skills tool (closes #1130)

Switches skill discovery from a pre-turn pipeline to an LLM-callable tool.
The model now decides when to search for a skill, judges the results, and
either invokes one or responds directly. Multi-skill turns and context-aware
clarifications become possible.

## Agent layer
- `search_skills(query, limit?)` exposed every turn (renamed from
  `find_skills`, with a flatter response shape: `{query, matches: [...]}`
  carrying raw confidence scores so the model judges relevance itself).
- Pre-LLM dispatch path removed from `LocalAgentLoop::run_turn`:
  - `is_ambiguous` heuristic + `AMBIGUOUS_MESSAGE_WORD_LIMIT` constant
  - `CLARIFYING_QUESTION` constant + hardcoded short-circuit
  - `skill_pipeline.find_skill()` resolution + scoped-prompt branch
  - `skill_pipeline.scope_tools()` whitelist filtering
  - `SkillPipeline` / `SkillMatch` / `SkillPipelineConfig` types
  - `intent` module (only consumer was the removed pipeline)
- Empty model output with no tool calls now surfaces as `InferenceError`
  (it's an inference bug, not a UX surface).
- `SkillPipeline` collapsed to a thin wrapper that only re-exports the
  seed templates used by first-run DB seeding (issue #1130 explicitly
  scopes the change to the control flow, not the skill definitions).

## Storage layer
- `SurrealStore::search_embeddings_by_node_type` — linear cosine scan
  restricted by `node.node_type` record-link traversal. Skips HNSW for
  high-selectivity types where a tiny candidate set (e.g. ~8-20 skill
  nodes) makes a linear scan both faster and exact, sidestepping HNSW's
  global top-K approximation + post-filter.
- `EmbeddingService::semantic_search_nodes_of_type` — thin wrapper that
  embeds the query and delegates to the new store method.
- `skill_ops::find_skills` rewritten to use the typed scan and return
  the flat shape; HIGH/MEDIUM confidence bucketing + `recommendation`
  strings dropped (model judges from raw scores). MCP `find_skills`
  handler inherits the new shape transparently.

## Prompts
- `TOOL_STRATEGY_RULES`, fallback prompt, and byte-locking drift test
  updated with a `search_skills` strategy line that calls out the
  empty-matches signal so small models don't loop retrying the tool.

## Tests
New coverage in `agent_loop.rs`:
- `model_calls_search_skills_then_invokes_matched_skill`
- `model_can_respond_without_calling_search_skills`
- `search_skills_can_be_called_multiple_times_in_a_turn`
- `empty_model_response_with_no_tools_returns_error`

New coverage in `tools.rs`:
- `search_skills_schema_exposes_optional_limit`
- `search_skills_description_mentions_empty_signal` (load-bearing
  wording check — small models retry rephrased queries otherwise)

Removed Category 8 of `agent_e2e.rs` (skill-pipeline scoping tests) — it
exercised the deleted pre-LLM routing behaviour.

All tests pass: vitest 3918, nodespace-core 1182, nodespace-agent 202,
nodespace-app 54 (25 + 27 + 2). cargo clippy + fmt + eslint + svelte-check
clean via `bun run quality:fix`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address PR #1149 review feedback

Addresses findings from the /pragmatic-code-review pass on PR #1149.

**Critical (both addressed)**
- MCP `find_skills` handler (packages/core/src/mcp/handlers/skills.rs): drop
  the canned "Proceed with general capabilities" fallback so the response
  shape matches the local agent's `search_skills` tool ({query, matches: []}).
  External ACP agents now see the same flat shape and judge empty matches
  themselves, instead of receiving inconsistent payloads from the same
  `skill_ops::find_skills` backend.
- New `SurrealStore::search_embeddings_by_node_type` got four integration
  tests in `packages/core/tests/search_handler_test.rs`:
  - confident match: only the typed node returns (proves the
    `node.node_type = $node_type` record-link filter actually works)
  - empty result for unknown type with other types present
  - empty database returns empty (not error)
  - ranking + limit honoured

**Important**
- `SKILL_SEARCH_THRESHOLD` dropped from 0.3 to 0.0 in
  `packages/core/src/ops/skill_ops.rs`. The non-zero floor partially undid
  the issue's "let the model judge" principle by hiding the long tail.
- `MAX_SKILL_LIMIT` got a doc comment explaining the cap's rationale and
  when to revisit it.
- Missing AC test scenarios added in `agent_loop.rs`:
  - `multi_skill_turn_invokes_skill_tools_between_searches` replaces the
    weaker prior test; now exercises the full chain
    (search_skills → search_semantic → search_skills → create_node) so a
    regression that breaks tool dispatch after a second `search_skills`
    call is caught here.
  - `empty_search_skills_matches_let_model_clarify_with_context` covers
    the "no skill applies" path the issue called out.
- Empty-model-response error log gained structured fields (session_id,
  model, iteration, prompt/completion tokens, user_message_preview) so
  production dashboards can group these by model and replay sessions.
- Filed follow-up #1152 for the latency-measurement AC (requires a warm
  NLP engine + real local model; out of scope for the unit-test pass).

**Suggestions**
- Deleted the `SkillPipeline` wrapper struct entirely
  (`packages/agent/src/skill_pipeline.rs`). Free function `seed_skill_nodes()`
  is the only public surface now; three call sites updated (db.rs,
  ollama_integration.rs). Matches CLAUDE.md "no backward compatibility" rule.
- `MockToolExecutor::with_tool(name, schema, result)` builder added; the
  three new agent_loop tests use it instead of mutating internal `tools`
  / `results` fields.
- `search_skills` description now specifies the bounds ("up to 3 by default,
  max 10") so the model isn't guessing what N is.

**Skipped (1 of 12)**
- Finding 12 (`humanize_tool_name("search_skills")` → "skill search"
  reads ambiguously). Subjective wording call; the reviewer's suggested
  replacement ("available skill lookup") reads more awkwardly. The
  existing label fits the verb-of-activity pattern the other tools use
  ("node search", "semantic search", "node lookup").

All tests still pass:
- nodespace-core search_handler_test: 27 (was 23 + 4 new typed-scan tests)
- nodespace-agent lib: 202 (held steady — net change of +0 after deleting
  the wrapper test and adding two AC tests)
- nodespace-app lib + integration: 54 (25 + 27 + 2)
- vitest frontend: 3918 (128 files)
- cargo clippy + fmt + eslint + svelte-check clean via `bun run quality:fix`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address PR #1149 re-review feedback

Addresses I-1, S-1, S-2, S-3 from the second /pragmatic-code-review pass.

**I-1: Multi-skill test brittle to MAX_TOOL_ITERATIONS reductions**
- Added a compile-time `const _: () = assert!(MAX_TOOL_ITERATIONS >= 5, ...)`
  at the top of the agent_loop tests module. If someone bumps the constant
  below 5, the test crate fails to compile with a clear error message
  naming the affected test — better than a mysterious runtime failure
  where the multi-skill assertions silently start exercising the
  iteration-cap fallback path instead of the intended chain.

**S-1: Threshold doc said "every match" but filter is strict `>`**
- Tightened the SKILL_SEARCH_THRESHOLD doc comment in skill_ops.rs to
  describe what actually happens: every match with strictly positive
  cosine similarity (a zero match is orthogonal vector noise, which the
  store filter `composite_score > $threshold` correctly excludes).

**S-2: MCP find_skills tool description advertised "guidance" field**
- Updated find_skills tool description in mcp/handlers/tools.rs:1464 to
  match the new flat `{query, matches}` shape — same wording as the
  local agent's search_skills description, dropping the now-bogus
  "guidance" reference. Single source of truth for tool consumers.

**S-3: skill_ops doc missed `id` field**
- Added `id` to the field list in the find_skills doc comment.

**Skipped: none.** All four addressed.

Verification:
- nodespace-agent lib: 202 passed (the compile-time assert would have
  blocked the build if violated, so the count holding steady is the
  meaningful signal here, not the pass count itself)
- nodespace-core lib: 1182 passed
- cargo fmt + cargo clippy -p nodespace-agent -p nodespace-core clean
- Note: `bun run quality:fix` cannot complete on this branch right now
  because origin/main has a pre-existing breakage in nodespace-cli and
  nodespace-daemon tests (CreateNodeRequest gained `id` and
  `insert_after_node_id` fields in PR #1147, but the cli/daemon test
  fixtures were not updated). Verified this failure reproduces with
  origin/main's version of those files checked out into our worktree —
  not introduced by this branch. Will file a separate issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Michael Libio <malibio@Michaels-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace pre-LLM skill routing with LLM-orchestrated skill search

1 participant