Repository navigation
Replace pre-LLM skill routing with LLM-orchestrated search_skills (closes #1130) - #1149
Conversation
malibio
left a comment
There was a problem hiding this comment.
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 ismodel_can_respond_without_calling_search_skillswhich exercises the model deciding not to search at all. The actual scenario (model searches, getsmatches: [], 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_turnonly verifiessearch_skillsis 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 bothsearch_semanticandcreate_nodeget invoked in the same turn after twosearch_skillscalls. 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
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>
43571d5 to
5e195c2
Compare
Review feedback addressedBranch is rebased on Critical (2/2 addressed)
Important (5/5 addressed)
Suggestions (3/4 addressed, 1 skipped)
Skipped (1/12)
Test results
Re-review recommendationRE-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
left a comment
There was a problem hiding this comment.
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_embeddingcallsstore.upsert_embeddings, which atpackages/core/src/db/surreal_store.rs:4159explicitly setsstale: false. So the text node's embedding IS indexed and IS a candidate.- The SurrealQL
WHERE stale = false AND node.node_type = $node_typeatpackages/core/src/db/surreal_store.rs:4575is 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_nodeat line 725 bindsnode_type: $node_typedirectly into the node row, so the record-link traversalnode.node_typeis 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→ loopfor iteration in 0..5runs iterations 0-4- iter 0: round 1 (search_skills) → executes tool, falls through (3 != 4 in
iteration == 4check) - 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>
Re-review feedback addressedNew commit Important (1/1)
Suggestions (3/3)
Verification
Process note (filed as #1156)
Re-review decisionNO 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. |
…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>
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 callsearch_skillsmore 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 vianode.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_skillsexposed every turn — renamed fromfind_skillswith a flatter response ({query, matches: [...]}) carrying raw confidence floats; HIGH/MEDIUM bucketing andrecommendationstrings dropped so the model judges.is_ambiguousheuristic,CLARIFYING_QUESTIONconstant, scoped-prompt branch,SkillPipeline::find_skill,scope_tools,SkillMatch,SkillPipelineConfig, and theintentmodule (only consumer was the removed pipeline).InferenceError(inference bug, not UX surface).SkillPipelinecollapsed 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 existingembedding.noderecord link with the same chunk aggregation + density-ratio scoring assearch_embeddings. No schema change required.EmbeddingService::semantic_search_nodes_of_type— thin service-layer wrapper.skill_ops::find_skillsrewritten to use the typed scan and return the flat shape. MCPfind_skillshandler inherits the new shape transparently.Prompts (
packages/agent/src/{agent_guidance,prompt_assembler,local_agent/prompt_templates}.rs)TOOL_STRATEGY_RULESand the fallback prompt gain asearch_skillsstrategy line. The wording explicitly calls out that an emptymatchesarray 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 inprompt_assembler.rsupdated accordingly.Test plan
bun run test— 3918 passed, 0 failed (frontend baseline)cargo test --lib -p nodespace-core— 1182 passed, 0 failedcargo 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 failedbun run quality:fix— eslint, svelte-check, cargo fmt, cargo clippy all cleanagent_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)tools.rs:search_skills_schema_exposes_optional_limitsearch_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-enginewith 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 callsearch_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
search_skills(replacesfind_skillsfor the local agent only; MCP's separatefind_skillshandler exposed to external ACP agents stays unchanged).{query, matches: [{id, name, description, confidence, tools}]}. Confidence is a rawf64so the model sees the full signal; tool whitelist included for downstream invocation context.matches: []rather than a canned "no skills found" message, per the issue's "let the model judge" principle.TODO(perf)was addressed in this PR via the dedicated linear-scan query for typed embedding search. The generalsemantic_search_nodespath 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