newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
//! The `tool_search` discovery seam (#725) — let the model find a *real* tool
//! by intent instead of fabricating a foreign name and burning a round.
//!
//! This is the structural complement to the alias seam (#716, which catches
//! foreign names *after* the fact) and the phantom-reach telemetry (#717, which
//! measures *which* names get fabricated). Where those react to a guess,
//! `tool_search` lets a model that only half-remembers a capability *look it up*
//! first: given a query it returns matching real tool names + a one-line
//! description (and required params) drawn from the current turn's authorized
//! catalog — so `read_file` / `update_plan` / `crew` are discoverable by
//! description rather than by lucky recall of an exact name.
//!
//! The catalog may be wider than the provider-wire projection: budget and
//! provider-count clipping can hide schemas without changing authorization.
//! `tool_search` lists those hidden tools. An exact-name query asks the existing
//! exposure controller to load that schema for the next request; it never runs
//! the named operation. Fuzzy and empty queries remain pure discovery.
//!
//! [`execute_tool_search`] is deliberately PURE — it takes the catalog as a
//! `&serde_json::Value` (the same shape [`super::tools::merged_tool_definitions`]
//! produces) plus the query, so it is unit-testable without a live session. The
//! dispatch arm in `tools.rs` builds the catalog for THIS session and does the
//! on-screen print; the matching logic lives here.

/// Model-facing contract for `tool_search`. Coaches a small local LLM that is
/// unsure of a tool's exact name to *look it up* rather than guess a name from
/// another harness.
const TOOL_SEARCH_DESCRIPTION: &str =
    "Search newt's tools available to the current turn by intent/keyword — returns matching real \
     tool names + what they do. Use this when unsure of a tool's exact name \
     instead of guessing. To load a tool missing from your callable list, query its exact name; \
     its schema will be included in the next request if a slot is available. Then call the tool \
     normally. Search never executes the named tool or grants permission.";

/// Max number of ranked matches returned for a query (keeps the payload compact
/// for a small model's window; a too-broad query is told to refine).
const MAX_MATCHES: usize = 12;

/// Query terms that never score: they occur in nearly every description, so a
/// query such as "read a file" matched almost the whole catalog and let noise
/// crowd real matches out of [`MAX_MATCHES`] (#2332). Terms under two
/// characters are dropped as well.
const STOP_TERMS: &[&str] = &["a", "an", "the", "to", "of", "for", "in", "on", "my", "me"];

/// Truncate a one-line description to this many chars (keeps each match to a
/// single readable line).
const DESC_MAX_CHARS: usize = 140;

/// The `tool_search` tool definition. Advertised ALWAYS (a discovery tool must
/// always be present, like `resume_context`) — `tools.rs` pushes it
/// unconditionally in `merged_tool_definitions`.
pub fn tool_search_tool_definition() -> serde_json::Value {
    serde_json::json!({
        "type": "function",
        "function": {
            "name": "tool_search",
            "description": TOOL_SEARCH_DESCRIPTION,
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Intent or keyword(s) to search the tool catalog by — \
                                        e.g. \"read a file\", \"plan\", \"delegate\". \
                                        An exact tool name also requests its schema for the next request."
                    }
                },
                "required": ["query"]
            }
        }
    })
}

/// One advertised tool, projected out of a catalog entry for matching.
struct ToolRec<'a> {
    name: &'a str,
    desc: &'a str,
    params: Vec<&'a str>,
    required: Vec<&'a str>,
    /// `false` for a tool this session grants but this request's disposition
    /// does not admit (#2332): it is found, named, and never offered a schema.
    callable: bool,
    /// `false` for an authorized tool whose schema is off the wire this turn
    /// (#2331): described, marked, and loadable by an exact-name search.
    loaded: bool,
}

/// Collapse whitespace (incl. embedded newlines from wrapped string literals)
/// into single spaces and truncate to `max` chars, so each match renders on one
/// line. Pure.
fn one_line(s: &str, max: usize) -> String {
    let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
    if collapsed.chars().count() > max {
        let head: String = collapsed.chars().take(max).collect();
        format!("{}…", head.trim_end())
    } else {
        collapsed
    }
}

/// Project a catalog (`[{type,function:{name,description,parameters}}]`) into the
/// matchable [`ToolRec`]s, skipping `tool_search` itself (a discovery tool need
/// not return itself, which would only invite recursion confusion).
fn records(catalog: &serde_json::Value) -> Vec<ToolRec<'_>> {
    let entries = catalog.as_array().map(Vec::as_slice).unwrap_or(&[]);
    let mut out = Vec::with_capacity(entries.len());
    for e in entries {
        let f = &e["function"];
        let Some(name) = f["name"].as_str() else {
            continue;
        };
        if name == "tool_search" {
            continue;
        }
        let desc = f["description"].as_str().unwrap_or("");
        let params: Vec<&str> = f["parameters"]["properties"]
            .as_object()
            .map(|o| o.keys().map(String::as_str).collect())
            .unwrap_or_default();
        let required: Vec<&str> = f["parameters"]["required"]
            .as_array()
            .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect())
            .unwrap_or_default();
        out.push(ToolRec {
            name,
            desc,
            params,
            required,
            callable: true,
            loaded: true,
        });
    }
    out
}

/// Format one match line: `- name — one-line description [requires: a, b]`.
fn match_line(r: &ToolRec) -> String {
    if !r.callable {
        let hidden = &super::DispositionVoices::default().discovery_hidden;
        return format!("- {} — {hidden}", r.name);
    }
    let desc = one_line(r.desc, DESC_MAX_CHARS);
    let line = if r.required.is_empty() {
        format!("- {} — {desc}", r.name)
    } else {
        format!(
            "- {} — {desc} [requires: {}]",
            r.name,
            r.required.join(", ")
        )
    };
    if r.loaded {
        line
    } else {
        format!("{line} {}", super::tools::exposure::HIDDEN_SEARCH_MARK)
    }
}

/// Search the advertised tool `catalog` by `query` and render a compact, ranked
/// list of matching REAL tool names + what they do. Pure (no fs / no global) —
/// unit-tested directly.
///
/// Matching is case-insensitive over each tool's **name + description + param
/// names**: a query term in the name scores highest, a hit in the description or
/// a param name scores lower, summed across the query's terms. Matches are
/// returned ranked (score desc, then name asc for determinism), capped at
/// [`MAX_MATCHES`].
///
/// Discovery never dead-ends:
/// - an empty query lists every advertised tool with its description, and
/// - a query that matches nothing returns the full list of tool **names**
///   (so the model always gets a real next step, never an empty result).
pub(crate) fn execute_tool_search(query: &str, catalog: &serde_json::Value) -> String {
    search(query, records(catalog))
}

fn search(query: &str, tools: Vec<ToolRec<'_>>) -> String {
    let terms: Vec<String> = query
        .to_lowercase()
        .split(|c: char| !c.is_alphanumeric())
        .filter(|t| t.chars().count() >= 2 && !STOP_TERMS.contains(t))
        .map(str::to_string)
        .collect();

    // Empty / term-less query: list the whole catalog with descriptions. A blank
    // probe is a legitimate "what can you do?" — answer it fully.
    if terms.is_empty() {
        if tools.is_empty() {
            return "No tools are available in the current turn.".to_string();
        }
        let body = tools.iter().map(match_line).collect::<Vec<_>>().join("\n");
        return with_scope_note(format!("Available tools in this turn:\n{body}"), &tools);
    }

    // Score each tool over name (×3) + description (×1) + param names (×1).
    let mut scored: Vec<(i64, &ToolRec)> = Vec::new();
    for r in &tools {
        let name_l = r.name.to_lowercase();
        let desc_l = r.desc.to_lowercase();
        let params_l: Vec<String> = r.params.iter().map(|p| p.to_lowercase()).collect();
        let mut score = 0i64;
        for term in &terms {
            if name_l.contains(term.as_str()) {
                score += 3;
            }
            if desc_l.contains(term.as_str()) {
                score += 1;
            }
            if params_l.iter().any(|p| p.contains(term.as_str())) {
                score += 1;
            }
        }
        if score > 0 {
            scored.push((score, r));
        }
    }

    // No match: return the full NAME list so discovery still succeeds.
    if scored.is_empty() {
        let names = tools
            .iter()
            .filter(|r| r.callable)
            .map(|r| r.name)
            .collect::<Vec<_>>()
            .join(", ");
        if names.is_empty() {
            return format!(
                "No tool matched \"{query}\" — no tools are available in the current turn."
            );
        }
        return format!(
            "No tool matched \"{query}\". Available tools: {names}. Retry tool_search with a \
             different keyword, or call one of these directly."
        );
    }

    // Rank: score desc, callable before not-callable at equal score, then name
    // asc (stable, deterministic).
    scored.sort_by(|a, b| {
        b.0.cmp(&a.0)
            .then_with(|| b.1.callable.cmp(&a.1.callable))
            .then_with(|| a.1.name.cmp(b.1.name))
    });

    // Callable and not-callable entries are capped separately (#2332): a
    // not-callable entry ranks among the callable ones but never takes a
    // callable match's slot, so naming what is hidden cannot push out a tool
    // the model may call.
    let mut shown: Vec<&ToolRec> = Vec::new();
    let (mut callable, mut hidden) = (0, 0);
    for (_, r) in &scored {
        let count = if r.callable {
            &mut callable
        } else {
            &mut hidden
        };
        if *count < MAX_MATCHES {
            *count += 1;
            shown.push(r);
        }
    }
    let mut out = format!("Tools matching \"{query}\":\n");
    for r in &shown {
        out.push_str(&match_line(r));
        out.push('\n');
    }
    if scored.len() > shown.len() {
        out.push_str(&format!(
            "…and {} more — refine the query.",
            scored.len() - shown.len()
        ));
    }
    with_scope_note(out.trim_end().to_string(), shown)
}

/// Append the scope note when a listed tool is not callable, and only then, so
/// it never guesses at a tool the session lacks. #2051: the note is
/// disposition vocabulary, owned by `disposition_voice`.
fn with_scope_note<'a>(out: String, shown: impl IntoIterator<Item = &'a ToolRec<'a>>) -> String {
    if shown.into_iter().all(|r| r.callable) {
        return out;
    }
    format!(
        "{out}\n\n{}",
        super::DispositionVoices::default().discovery_scope
    )
}

/// Search `catalog` — the tools this session grants — for a request under
/// `disposition` (#2332). Tools the disposition does not admit are ranked with
/// the callable ones and rendered by name as not callable, never as absent and
/// never with a schema; dispatch still refuses them. A tool the session does
/// not grant is not in `catalog`, so it never appears.
pub(crate) fn execute_tool_search_for_disposition(
    query: &str,
    catalog: &serde_json::Value,
    disposition: super::PromptDisposition,
    hidden: Option<&super::tools::HiddenTools>,
) -> String {
    fn mark<'a>(mut r: ToolRec<'a>, hidden: Option<&super::tools::HiddenTools>) -> ToolRec<'a> {
        r.loaded = !hidden.is_some_and(|hidden| hidden.is_hidden(r.name));
        r
    }
    if disposition == super::PromptDisposition::Act {
        return search(
            query,
            records(catalog)
                .into_iter()
                .map(|r| mark(r, hidden))
                .collect(),
        );
    }
    // The callable half goes through the disposition filter so the read-only
    // `git` definition, not the full one, is what gets described.
    let visible = super::tools::filter_tools_for_disposition(catalog.clone(), disposition);
    let mut tools: Vec<ToolRec<'_>> = records(&visible)
        .into_iter()
        .map(|r| mark(r, hidden))
        .collect();
    tools.extend(
        records(catalog)
            .into_iter()
            .filter(|r| !super::tools::tool_allowed(disposition, r.name))
            .map(|r| ToolRec {
                callable: false,
                ..r
            }),
    );
    search(query, tools)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agentic::tools::merged_tool_definitions;
    use crate::agentic::NoMcp;

    /// The full advertised catalog with every presence gate ON, so crew /
    /// plan / scratchpad / etc. all appear — the richest catalog to search.
    fn full_catalog() -> serde_json::Value {
        let git = Some(&crate::caveats::Scope::All);
        merged_tool_definitions(
            &NoMcp,
            true,
            true,
            true,
            git,
            true,
            true,
            true,
            true,
            true,
            true,
            true,
            true,
            Default::default(),
            crate::agentic::shell_dialect_sentence(false, false),
        )
    }

    #[test]
    fn search_read_includes_read_file() {
        let out = execute_tool_search("read", &full_catalog());
        assert!(out.contains("read_file"), "got: {out}");
    }

    #[test]
    fn search_plan_includes_update_plan() {
        // `update_plan` (the scheduled write tool) is advertised with the gate on.
        let out = execute_tool_search("plan", &full_catalog());
        assert!(out.contains("update_plan"), "got: {out}");
    }

    #[test]
    fn search_description_only_match() {
        // "team" appears in the crew / compose_roster DESCRIPTIONS but in no tool
        // NAME — proves description matching works, not just name matching.
        let out = execute_tool_search("team", &full_catalog());
        assert!(
            out.contains("crew") || out.contains("compose_roster"),
            "description-only match should find crew/compose_roster: {out}"
        );
    }

    #[test]
    fn no_match_returns_full_name_list_not_dead_end() {
        let out = execute_tool_search("zzzznotarealtoolname", &full_catalog());
        // Never empty / a dead end — the full name list is returned so the model
        // can still pick a real tool.
        assert!(out.contains("read_file"), "got: {out}");
        assert!(out.contains("write_file"), "got: {out}");
        assert!(!out.trim().is_empty());
    }

    #[test]
    fn search_is_case_insensitive() {
        let cat = full_catalog();
        let lower = execute_tool_search("read", &cat);
        let upper = execute_tool_search("READ", &cat);
        assert!(upper.contains("read_file"), "got: {upper}");
        // The header echoes the raw query (case preserved), so compare the match
        // bodies (everything after the first line) — they must be identical.
        let body = |s: &str| {
            s.split_once('\n')
                .map(|(_, b)| b.to_string())
                .unwrap_or_default()
        };
        assert_eq!(
            body(&lower),
            body(&upper),
            "matched tools must be identical regardless of query case"
        );
    }

    #[test]
    fn does_not_list_itself() {
        // A discovery tool need not return itself.
        let out = execute_tool_search("search", &full_catalog());
        assert!(
            !out.contains("- tool_search "),
            "tool_search should not appear in its own results: {out}"
        );
    }

    #[test]
    fn empty_query_lists_everything() {
        let out = execute_tool_search("", &full_catalog());
        assert!(out.contains("Available tools in this turn:"), "got: {out}");
        assert!(out.contains("read_file"), "got: {out}");
        assert!(out.contains("write_file"), "got: {out}");
    }

    #[test]
    fn empty_catalog_is_scoped_to_the_current_turn_not_the_session() {
        let out = execute_tool_search("run_command", &serde_json::json!([]));
        assert!(out.contains("current turn"), "got: {out}");
        assert!(!out.contains("this session"), "got: {out}");
    }

    /// #2332: discovery is not fenced. `run_command` is authorized in this
    /// session but not callable for an Explain request; the search used to run
    /// over the already-filtered catalog and so reported it absent. It is now
    /// named as present-but-not-callable, with the handoff, and is never listed
    /// as a callable match. Explicit Plan narrows the catalog; inferred
    /// response style preserves the session's tool surface.
    #[test]
    fn explicit_plan_search_reports_a_hidden_tool_as_present_not_absent() {
        let out = execute_tool_search_for_disposition(
            "run_command",
            &full_catalog(),
            super::super::PromptDisposition::Plan,
            None,
        );
        let voices = super::super::DispositionVoices::default();
        assert!(
            out.contains(&format!("- run_command — {}", voices.discovery_hidden)),
            "an authorized tool must be reported hidden, not absent: {out}"
        );
        assert!(out.contains(&voices.discovery_scope), "got: {out}");
        assert!(
            !out.contains("Run a shell command"),
            "a hidden tool must not be offered with its description: {out}"
        );
        // #2051: the note never names the disposition, and never tells the
        // model it has no move.
        assert!(!out.contains("Explain"), "got: {out}");
        assert!(!out.contains("explain"), "got: {out}");
        assert!(
            !out.contains("cannot widen an already accepted turn"),
            "got: {out}"
        );
    }

    /// A tool absent from the actual session catalog must not be invented.
    #[test]
    fn explain_search_does_not_invent_an_ungranted_tool() {
        let mut granted = full_catalog();
        granted
            .as_array_mut()
            .unwrap()
            .retain(|def| def["function"]["name"] != "run_command");
        let out = execute_tool_search_for_disposition(
            "run_command",
            &granted,
            super::super::PromptDisposition::Explain,
            None,
        );
        assert!(!out.contains("- run_command"), "got: {out}");
    }

    /// A read search under Explain still leads with the callable reader;
    /// listing hidden tools must not push it down or strip its schema.
    #[test]
    fn explain_read_search_leads_with_the_callable_reader() {
        let out = execute_tool_search_for_disposition(
            "read a file",
            &full_catalog(),
            super::super::PromptDisposition::Explain,
            None,
        );
        let first = out.lines().nth(1).unwrap_or_default();
        assert!(first.starts_with("- read_file — Read a file"), "got: {out}");
        assert!(first.contains("[requires: path]"), "got: {out}");
    }

    /// Listing hidden tools must never push out a callable match that the
    /// disposition-filtered search returns on its own. Under one shared cap,
    /// "read a file" lost `where_is`, `experience_recall` and `list_dir` to the
    /// higher-scoring `write_file`/`edit_file`/`delete_file` entries even with
    /// [`STOP_TERMS`]; the separate caps in `search` are what hold this.
    #[test]
    fn hidden_entries_never_displace_a_callable_match() {
        let explain = super::super::PromptDisposition::Explain;
        let catalog = full_catalog();
        let filtered = crate::agentic::filter_tools_for_disposition(catalog.clone(), explain);
        let voices = super::super::DispositionVoices::default();
        let matches = |out: &str| -> Vec<String> {
            out.lines()
                .filter_map(|line| line.strip_prefix("- "))
                .filter(|line| !line.contains(voices.discovery_hidden.as_str()))
                .filter_map(|line| line.split(" — ").next())
                .map(str::to_string)
                .collect()
        };
        let mut checked = 0;
        for query in [
            "read a file",
            "search the code",
            "list files",
            "git log",
            "fetch a url",
            "notes",
        ] {
            let callable = matches(&execute_tool_search(query, &filtered));
            checked += callable.len();
            let mixed = execute_tool_search_for_disposition(query, &catalog, explain, None);
            let shown = matches(&mixed);
            let lost: Vec<&String> = callable.iter().filter(|t| !shown.contains(t)).collect();
            assert!(
                lost.is_empty(),
                "{query:?}: hidden entries displaced {lost:?}: {mixed}"
            );
        }
        // A parser that read nothing would pass the loop vacuously.
        assert!(
            checked > 12,
            "only {checked} callable matches were compared"
        );
    }

    /// Filler terms do not score: "read a file" ranks exactly like "read
    /// file". Without [`STOP_TERMS`], "a" matches nearly every name and
    /// description and reorders the whole result.
    #[test]
    fn filler_terms_do_not_score() {
        let catalog = full_catalog();
        let body = |query: &str| {
            let out = execute_tool_search(query, &catalog);
            out.split_once('\n')
                .map(|(_, b)| b.to_string())
                .unwrap_or_default()
        };
        assert_eq!(body("read a file"), body("read file"));
        assert_eq!(body("search the code"), body("search code"));
    }

    #[test]
    fn match_line_renders_required_params() {
        // read_file requires `path`; its match line should surface that.
        let out = execute_tool_search("read_file", &full_catalog());
        assert!(out.contains("read_file"), "got: {out}");
        assert!(out.contains("requires:"), "required params shown: {out}");
    }

    #[test]
    fn lifecycle_discovery_exposes_build_authority_in_short_description() {
        let out = execute_tool_search("lifecycle", &full_catalog());
        let lifecycle = out
            .lines()
            .find(|line| line.starts_with("- lifecycle —"))
            .unwrap();
        assert!(lifecycle.contains("action=build"), "{lifecycle}");
        assert!(lifecycle.contains("explicit"), "{lifecycle}");
        assert!(lifecycle.contains("offline"), "{lifecycle}");
    }

    #[test]
    fn one_line_collapses_whitespace_and_truncates() {
        let collapsed = one_line("a\n   b\t c", 100);
        assert_eq!(collapsed, "a b c");
        let truncated = one_line(&"x".repeat(200), 10);
        assert!(truncated.chars().count() <= 11, "got: {truncated}"); // 10 + ellipsis
        assert!(truncated.ends_with('…'));
    }
}