@selesai/code 0.13.34 → 0.13.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.35] - 2026-09-29
6
+
7
+ ### Added
8
+ - **`jev_find` finds files by what they do.** ripgrep gathers up to 48 candidate files (matching `pattern`, or listed under `path`/`glob` and ranked by question words), Jev judges each file's best-matching lines against a plain-words `question` in parallel batches of 16, and the agent gets ranked `path:lines (relevance)` pointers instead of file contents. It respects `.gitignore`, stays inside the working directory, skips secret-named files, and returns the unranked candidates when Jev is unavailable. It ships with `ask_jev` under `jevAdvisory.routes.ask`, stays active under the capability gateway, and leaves the loadout while Jev has no credential.
9
+ - **Jev can set up a subagent launch (opt-in).** With `jevAdvisory.routes.subagent.enabled`, Jev fills only what the parent left open on a single-child `subagent` call: it picks an agent by function when `agent` is omitted or generic (`genericAgents`, default `["delegate"]`), may drop tools from that agent's declared list (never `read` or supervision tools), and picks a `simple`/`complex`/`reasoning` model from `tiers` when no `model` was passed. Any abstention launches exactly what was asked.
10
+
11
+ ### Changed
12
+ - **The agent reaches for Jev when locating code.** The `jev_find` guidance tells the agent to use it before grep, find, or reading candidate files when it searches by behavior rather than an exact identifier; exact-name lookups still go to grep.
13
+
5
14
  ## [0.13.34] - 2026-09-29
6
15
 
7
16
  ### Added
@@ -73,9 +73,10 @@ export const GATEWAY_TOOLS = new Set(["capability_catalog", "capability_discover
73
73
 
74
74
  // Graft supplies pre-turn hybrid context and must remain callable for precise
75
75
  // follow-ups; making it dormant defeats both paths. `ask_jev` is the agent's own
76
- // decision surface for Jev, so it is never something the agent must discover.
76
+ // decision surface for Jev (and `jev_find` its file finder), so neither is something the agent must discover.
77
77
  const ALWAYS_ACTIVE_EXTENSION_TOOLS = new Set([
78
78
  "ask_jev",
79
+ "jev_find",
79
80
  "graft_check_freshness",
80
81
  "graft_file_api",
81
82
  "graft_find_all",
@@ -28,7 +28,7 @@ import type { AssistantMessage, Context, Model } from "@earendil-works/pi-ai";
28
28
  // Advisory configuration (one opt-in area, per-route enablement)
29
29
  // ---------------------------------------------------------------------------
30
30
 
31
- export const JEV_ROUTE_NAMES = ["memory", "recommendations", "ask"] as const;
31
+ export const JEV_ROUTE_NAMES = ["memory", "recommendations", "ask", "subagent"] as const;
32
32
  export type JevRouteName = (typeof JEV_ROUTE_NAMES)[number];
33
33
 
34
34
  export interface JevRouteConfig {
@@ -83,6 +83,17 @@ export const DEFAULT_JEV_ASK_ROUTE_CONFIG: JevRouteConfig = {
83
83
  payloadBytes: 32 * 1024,
84
84
  };
85
85
 
86
+ /**
87
+ * The `subagent` route sets up a public single-child launch (agent, tools, model tier; see
88
+ * pi-subagents' jev-subagent-routing.ts). Opt-in, and it sits on the launch path, so its
89
+ * deadline is shorter than the ask route's; its state is the task plus agent descriptions.
90
+ */
91
+ export const DEFAULT_JEV_SUBAGENT_ROUTE_CONFIG: JevRouteConfig = {
92
+ ...DEFAULT_JEV_ROUTE_CONFIG,
93
+ timeoutMs: 5_000,
94
+ payloadBytes: 16 * 1024,
95
+ };
96
+
86
97
  export const DEFAULT_JEV_ADVISORY_CONFIG: JevAdvisoryConfig = {
87
98
  provider: "tokenin",
88
99
  model: "jev-1.13",
@@ -90,6 +101,7 @@ export const DEFAULT_JEV_ADVISORY_CONFIG: JevAdvisoryConfig = {
90
101
  memory: { ...DEFAULT_JEV_ROUTE_CONFIG },
91
102
  recommendations: { ...DEFAULT_JEV_ROUTE_CONFIG },
92
103
  ask: { ...DEFAULT_JEV_ASK_ROUTE_CONFIG },
104
+ subagent: { ...DEFAULT_JEV_SUBAGENT_ROUTE_CONFIG },
93
105
  },
94
106
  };
95
107
 
@@ -153,6 +165,7 @@ export function readJevAdvisoryConfig(settingsPath: string): JevAdvisoryConfig {
153
165
  memory: routeOr(routes.memory),
154
166
  recommendations: routeOr(routes.recommendations),
155
167
  ask: routeOr(routes.ask, DEFAULT_JEV_ASK_ROUTE_CONFIG),
168
+ subagent: routeOr(routes.subagent, DEFAULT_JEV_SUBAGENT_ROUTE_CONFIG),
156
169
  },
157
170
  };
158
171
  }
@@ -19,6 +19,8 @@ const state = vi.hoisted(() => ({
19
19
 
20
20
  vi.mock("@selesai/code", () => ({
21
21
  getSettingsPath: () => state.settingsPath,
22
+ // jev_find runs the real ripgrep on PATH against the temp repo.
23
+ ensureTool: async () => "rg",
22
24
  // The same local shell backend the bash tool uses, scripted per command.
23
25
  createLocalBashOperations: () => ({
24
26
  exec: async (command: string, _cwd: string, options: { onData: (data: Buffer) => void }) => {
@@ -30,8 +32,8 @@ vi.mock("@selesai/code", () => ({
30
32
  }),
31
33
  }));
32
34
 
33
- import jevAskToolExtension, { askPath, MAX_ASK_QUESTIONS } from "./jev-ask-tool.ts";
34
- import { JEV_ROUTING_EVENT } from "./jev/decisions.ts";
35
+ import jevAskToolExtension, { askPath, FIND_BATCH, fitFindPayload, MAX_ASK_QUESTIONS } from "./jev-ask-tool.ts";
36
+ import { JEV_ROUTING_EVENT, serializeJevRequest } from "./jev/decisions.ts";
35
37
  import { jevResponse, providerTemplate } from "./jev/test-support.ts";
36
38
 
37
39
  const FILE_SENTINEL = "FILE_BODY_MUST_RETURN_TO_JE ONLY";
@@ -89,6 +91,9 @@ interface AskHarness {
89
91
  ask(params: Record<string, unknown>, signal?: AbortSignal): Promise<{ text: string; details: Record<string, unknown> }>;
90
92
  /** The decision request Jev was sent, parsed. */
91
93
  sent(): Record<string, unknown> | undefined;
94
+ /** Every tool the extension registered, by name. */
95
+ tools: Record<string, ToolDefinition>;
96
+ ctx: unknown;
92
97
  }
93
98
 
94
99
  function harness(
@@ -114,10 +119,10 @@ function harness(
114
119
  const hooks: Record<string, (event: unknown, ctx: unknown) => unknown> = {};
115
120
  let credential = options.credential !== false;
116
121
  const complete = vi.fn(async () => jevResponse(answerText({})));
117
- let registered: ToolDefinition | undefined;
122
+ const tools: Record<string, ToolDefinition> = {};
118
123
  jevAskToolExtension({
119
124
  registerTool: (definition: ToolDefinition) => {
120
- registered = definition;
125
+ tools[definition.name] = definition;
121
126
  },
122
127
  on: (event: string, handler: (event: unknown, ctx: unknown) => unknown) => {
123
128
  hooks[event] = handler;
@@ -143,9 +148,11 @@ function harness(
143
148
  complete,
144
149
  },
145
150
  };
146
- const tool = registered as ToolDefinition;
151
+ const tool = tools.ask_jev as ToolDefinition;
147
152
  return {
148
153
  tool,
154
+ tools,
155
+ ctx,
149
156
  complete,
150
157
  telemetry,
151
158
  notices,
@@ -412,6 +419,64 @@ describe("missing material", () => {
412
419
  });
413
420
  });
414
421
 
422
+ describe("jev_find", () => {
423
+ const find = async (session: AskHarness, params: Record<string, unknown>) => {
424
+ const result = await session.tools.jev_find.execute("call-f", params, undefined, undefined, session.ctx);
425
+ return { text: result.content[0]?.text ?? "", details: result.details };
426
+ };
427
+
428
+ beforeAll(() => {
429
+ writeFileSync(join(repo, "src", "other.ts"), "// mentions round only in passing\nexport const x = 1;\n", "utf-8");
430
+ writeFileSync(join(repo, ".env"), "round=SECRET_ENV_VALUE\n", "utf-8");
431
+ });
432
+
433
+ it("ranks ripgrep's candidates by Jev relevance and returns pointers, never contents", async () => {
434
+ const session = harness();
435
+ // Jev: the file whose question names round.ts is relevant, the rest are not.
436
+ session.complete.mockImplementation(async (_model: unknown, request: { messages: Array<{ content: string }> }) => {
437
+ const sent = JSON.parse(request.messages[0].content) as { questions: Record<string, { instructions: { question: string } }> };
438
+ const answers = Object.fromEntries(
439
+ Object.entries(sent.questions).map(([name, q]) => [name, { noul: q.instructions.question.includes("round.ts") ? 0.93 : 0.08 }]),
440
+ );
441
+ return jevResponse(answerText(answers));
442
+ });
443
+ const result = await find(session, { question: "where is rounding implemented", pattern: "round", ignoreCase: true });
444
+
445
+ expect(result.text).toMatch(/judged 2 of 2 candidate/);
446
+ expect(result.text).toContain("- src/round.ts:1 (0.93)");
447
+ expect(result.text).not.toContain("other.ts");
448
+ expect(result.text).not.toContain(FILE_SENTINEL);
449
+ const sent = session.sent() as { state: { files: Record<string, string> } };
450
+ expect(Object.keys(sent.state.files).sort()).toEqual(["src/other.ts", "src/round.ts"]);
451
+ expect(sent.state.files["src/round.ts"]).toContain(FILE_SENTINEL);
452
+ expect(JSON.stringify(sent)).not.toContain("SECRET_ENV_VALUE");
453
+ });
454
+
455
+ it("lists a directory by glob and still returns candidates when Jev is unreachable", async () => {
456
+ const session = harness({ credential: false });
457
+ const result = await find(session, { question: "rounding", path: "src", glob: "*.ts" });
458
+
459
+ expect(session.complete).not.toHaveBeenCalled();
460
+ expect(result.text).toMatch(/Jev did not judge \(no-credential\); 2 candidate/);
461
+ // The question word in the path orders the unjudged listing.
462
+ expect(result.text.split("\n")[1]).toBe("- src/round.ts");
463
+ });
464
+
465
+ it("shrinks escape-heavy snippets until a full batch fits the request budget", () => {
466
+ // Quotes and newlines double under JSON escaping, which is what overflowed live.
467
+ const snippet = '"\n\t\\'.repeat(1_000);
468
+ const batch = Array.from({ length: FIND_BATCH }, (_, i) => ({ path: `src/f${i}.ts`, lines: [], snippet }));
469
+ const payload = fitFindPayload("q", batch, 32 * 1024);
470
+ expect(serializeJevRequest(payload, 32 * 1024)).toBeDefined();
471
+ expect(Object.keys(payload.questions as object)).toHaveLength(FIND_BATCH);
472
+ });
473
+
474
+ it("refuses a search root outside the working directory", async () => {
475
+ const result = await find(harness(), { question: "x", path: ".." });
476
+ expect(result.text).toContain("outside the working directory");
477
+ });
478
+ });
479
+
415
480
  describe("askPath symlinks", () => {
416
481
  it("refuses links that escape the working directory or point at a secret", () => {
417
482
  const root = mkdtempSync(join(tmpdir(), "ask-path-"));
@@ -13,11 +13,13 @@
13
13
  * tool's own abort signal. Every part is clipped to the route's payload budget
14
14
  * before the call, so an oversized request is trimmed rather than abstained.
15
15
  */
16
+ import { execFile } from "node:child_process";
16
17
  import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
17
- import { basename, isAbsolute, relative, resolve } from "node:path";
18
+ import { basename, isAbsolute, normalize, relative, resolve } from "node:path";
18
19
  import { StringEnum } from "@earendil-works/pi-ai";
19
20
  import {
20
21
  createLocalBashOperations,
22
+ ensureTool,
21
23
  type ExtensionAPI,
22
24
  type ExtensionContext,
23
25
  getSettingsPath,
@@ -339,6 +341,247 @@ export function renderAskAnswers(input: {
339
341
  return [...header, ...lines, ...footer].join("\n");
340
342
  }
341
343
 
344
+ // ---------------------------------------------------------------------------
345
+ // jev_find: ripgrep gathers candidates, Jev judges each one
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /** The file finder's registered name. */
349
+ export const JEV_FIND_TOOL = "jev_find";
350
+ /** ponytail: 16 nouls per request (the JevPDF batch size); raise once Jev is measured on larger blocks. */
351
+ export const FIND_BATCH = 16;
352
+ /** Three batches, sent in parallel, so a find costs one Jev round trip. */
353
+ export const MAX_FIND_CANDIDATES = 48;
354
+ const FIND_MATCH_LINES = 6;
355
+ const FIND_DEFAULT_LIMIT = 8;
356
+ const FIND_MIN_RELEVANCE = 0.5;
357
+ /** Room each batch keeps for the questions and JSON scaffolding. */
358
+ const FIND_QUESTION_RESERVE = 6 * 1024;
359
+
360
+ export interface FindCandidate {
361
+ path: string;
362
+ /** Matched line numbers (pattern mode only). */
363
+ lines: number[];
364
+ /** What Jev reads: the matched lines, or the head of the file. */
365
+ snippet: string;
366
+ }
367
+
368
+ /** Run ripgrep with an argument list (no shell). Exit 1 is "no matches"; an overfull buffer keeps what arrived. */
369
+ function runRg(bin: string, args: string[], cwd: string, signal: AbortSignal | undefined): Promise<string> {
370
+ return new Promise((done, fail) => {
371
+ execFile(
372
+ bin,
373
+ args,
374
+ { cwd, signal, maxBuffer: 8 * 1024 * 1024, timeout: ASK_COMMAND_TIMEOUT_SECONDS * 1000 },
375
+ (error, stdout) => {
376
+ const code = error ? (error as { code?: unknown }).code : 0;
377
+ if (!error || code === 1 || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") done(String(stdout));
378
+ else fail(error);
379
+ },
380
+ );
381
+ });
382
+ }
383
+
384
+ /** Filler words that would match nearly every file. */
385
+ const FIND_STOP_WORDS = new Set(
386
+ "the and for with where what which when who how does did this that from into its are was can should would there file files code find implement implemented implements".split(" "),
387
+ );
388
+
389
+ /** Question words worth searching for: 3+ letters, not filler, at most eight. */
390
+ function questionWords(question: string): string[] {
391
+ const words = question.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
392
+ return [...new Set(words)].filter((word) => !FIND_STOP_WORDS.has(word)).slice(0, 8);
393
+ }
394
+
395
+ /** How much of each listed file is scanned for question words. */
396
+ const FIND_SCAN_BYTES = 256 * 1024;
397
+ const FIND_EXCERPT_LINES = 12;
398
+
399
+ /**
400
+ * What Jev reads of a listed file: the lines holding the most distinct question words, in file
401
+ * order and numbered, or the head of the file when none match. A file's head is usually its
402
+ * doc comment, which rarely shows where the asked-about behavior lives.
403
+ */
404
+ export function excerpt(text: string, words: readonly string[], maxBytes: number): { lines: number[]; snippet: string } {
405
+ const rows = text.split("\n");
406
+ const best = rows
407
+ .map((row, index) => {
408
+ const lower = row.toLowerCase();
409
+ return { index, count: words.filter((word) => lower.includes(word)).length };
410
+ })
411
+ .filter((hit) => hit.count > 0)
412
+ .sort((a, b) => b.count - a.count || a.index - b.index)
413
+ .slice(0, FIND_EXCERPT_LINES);
414
+ if (best.length === 0) return { lines: [], snippet: clip(text, maxBytes).text };
415
+ const inOrder = [...best].sort((a, b) => a.index - b.index);
416
+ const snippet = inOrder.map((hit) => `L${hit.index + 1}: ${rows[hit.index].trim().slice(0, 240)}`).join("\n");
417
+ // Pointers lead with the best-matching lines.
418
+ return { lines: best.map((hit) => hit.index + 1), snippet: clip(snippet, maxBytes).text };
419
+ }
420
+
421
+ /** Path parts (3+ letters) sharing a stem with a question word; orders a file listing before it is capped. */
422
+ function pathOverlap(path: string, words: readonly string[]): number {
423
+ const parts = path.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
424
+ return parts.filter((part) => words.some((word) => word.includes(part) || part.includes(word))).length;
425
+ }
426
+
427
+ /**
428
+ * Candidate files under `root`, most promising first. With `pattern`, the files ripgrep matches
429
+ * (most matches first) with their matched lines as the snippet; without it, every file ripgrep
430
+ * lists (question words in the path first) with the head of the file as the snippet. Respects
431
+ * .gitignore, and never offers a file `askPath` would refuse.
432
+ */
433
+ export async function findCandidates(
434
+ options: { rg: string; cwd: string; root: string; question: string; pattern?: string; glob?: string; ignoreCase?: boolean },
435
+ snippetBytes: number,
436
+ signal: AbortSignal | undefined,
437
+ ): Promise<{ candidates: FindCandidate[]; total: number }> {
438
+ // Always name the root: with no path and a piped stdin, rg searches stdin and hangs.
439
+ // Paths come back as `./src/x.ts`, so they are normalized below.
440
+ const root = ["--", options.root];
441
+ const filters = [...(options.glob ? ["--glob", options.glob] : []), ...(options.ignoreCase ? ["-i"] : [])];
442
+ const allowed = (path: string) => "full" in askPath(path, options.cwd);
443
+ if (options.pattern) {
444
+ const out = await runRg(
445
+ options.rg,
446
+ ["--null", "--line-number", "--no-heading", "--color=never", "--max-count", String(FIND_MATCH_LINES), "--max-columns", "240", ...filters, "-e", options.pattern, ...root],
447
+ options.cwd,
448
+ signal,
449
+ );
450
+ const byFile = new Map<string, { lines: number[]; text: string[] }>();
451
+ for (const row of out.split("\n")) {
452
+ const nul = row.indexOf("\0");
453
+ if (nul < 0) continue;
454
+ const path = normalize(row.slice(0, nul));
455
+ const match = /^(\d+):(.*)$/.exec(row.slice(nul + 1));
456
+ if (!match) continue;
457
+ const entry = byFile.get(path) ?? { lines: [], text: [] };
458
+ entry.lines.push(Number(match[1]));
459
+ entry.text.push(`L${match[1]}: ${match[2].trim()}`);
460
+ byFile.set(path, entry);
461
+ }
462
+ const files = [...byFile].filter(([path]) => allowed(path)).sort((a, b) => b[1].lines.length - a[1].lines.length);
463
+ return {
464
+ total: files.length,
465
+ candidates: files.slice(0, MAX_FIND_CANDIDATES).map(([path, entry]) => ({
466
+ path,
467
+ lines: entry.lines,
468
+ snippet: clip(entry.text.join("\n"), snippetBytes).text,
469
+ })),
470
+ };
471
+ }
472
+ const words = questionWords(options.question);
473
+ const out = await runRg(options.rg, ["--files", "--color=never", ...filters, ...root], options.cwd, signal);
474
+ const listed = out
475
+ .split("\n")
476
+ .filter((path) => path !== "")
477
+ .map((path) => normalize(path))
478
+ .filter(allowed);
479
+ const score = new Map(listed.map((path) => [path, pathOverlap(path, words)]));
480
+ if (listed.length > MAX_FIND_CANDIDATES && words.length > 0) {
481
+ // More files than Jev judges: a file scores once per distinct question word its content holds,
482
+ // one fixed-string ripgrep per word, in parallel.
483
+ const hits = await Promise.all(
484
+ words.map((word) =>
485
+ runRg(options.rg, ["-l", "-i", "-F", "--color=never", ...filters, "-e", word, ...root], options.cwd, signal),
486
+ ),
487
+ );
488
+ for (const listing of hits) {
489
+ for (const raw of listing.split("\n")) {
490
+ const path = raw === "" ? "" : normalize(raw);
491
+ const current = score.get(path);
492
+ if (current !== undefined) score.set(path, current + 1);
493
+ }
494
+ }
495
+ }
496
+ const files = listed.map((path) => ({ path })).sort((a, b) => (score.get(b.path) ?? 0) - (score.get(a.path) ?? 0));
497
+ const candidates: FindCandidate[] = [];
498
+ for (const { path } of files.slice(0, MAX_FIND_CANDIDATES)) {
499
+ const file = readBounded(resolve(options.cwd, path), FIND_SCAN_BYTES);
500
+ if ("error" in file) continue;
501
+ candidates.push({ path, ...excerpt(file.text, words, snippetBytes) });
502
+ }
503
+ return { candidates, total: files.length };
504
+ }
505
+
506
+ /** One batch as a decisions request: the shared snippets as state, one noul per file. */
507
+ export function buildFindPayload(question: string, batch: readonly FindCandidate[]): Record<string, unknown> {
508
+ return buildAskPayload(
509
+ batch.map((candidate, index) => ({
510
+ name: `f${index}`,
511
+ type: "noul" as const,
512
+ instructions: `Is the file "${candidate.path}" relevant to the request, judged by its excerpt in state.files?`,
513
+ criteria: {
514
+ true: "The excerpt shows this file implements, defines, configures, or directly answers the request.",
515
+ false: "The file only mentions related words, or is about something else.",
516
+ },
517
+ })),
518
+ { request: question, files: Object.fromEntries(batch.map((candidate) => [candidate.path, candidate.snippet])) },
519
+ );
520
+ }
521
+
522
+ /**
523
+ * The batch's request, its snippets shrunk until the serialized request fits `maxBytes`: code
524
+ * grows under JSON escaping, so a byte budget per snippet alone does not guarantee a fit.
525
+ */
526
+ export function fitFindPayload(question: string, batch: readonly FindCandidate[], maxBytes: number): Record<string, unknown> {
527
+ let limit = Math.max(0, ...batch.map((candidate) => Buffer.byteLength(candidate.snippet, "utf-8")));
528
+ for (;;) {
529
+ const payload = buildFindPayload(
530
+ question,
531
+ batch.map((candidate) => ({ ...candidate, snippet: clip(candidate.snippet, limit).text })),
532
+ );
533
+ if (limit < 64 || serializeJevRequest(payload, maxBytes) !== undefined) return payload;
534
+ limit = Math.floor(limit * 0.75);
535
+ }
536
+ }
537
+
538
+ /** Pointers only: path, matched lines, relevance. File contents never reach the agent. */
539
+ export function renderFindResults(input: {
540
+ ranked: ReadonlyArray<FindCandidate & { relevance?: number }>;
541
+ total: number;
542
+ judged: number;
543
+ limit: number;
544
+ model: string;
545
+ elapsedMs: number;
546
+ failure?: string;
547
+ }): string {
548
+ const pointer = (candidate: FindCandidate) =>
549
+ candidate.lines.length > 0 ? `${candidate.path}:${candidate.lines.slice(0, 4).join(",")}` : candidate.path;
550
+ if (input.total === 0) return "jev_find: no candidate files. Loosen `pattern`, `glob`, or `path`.";
551
+ const lines: string[] = [];
552
+ if (input.judged === 0) {
553
+ lines.push(
554
+ `jev_find: Jev did not judge (${input.failure ?? "missing"}); ${input.total} candidate file(s), unranked by relevance:`,
555
+ ...input.ranked.slice(0, input.limit).map((candidate) => `- ${pointer(candidate)}`),
556
+ );
557
+ } else {
558
+ const strong = input.ranked.filter((candidate) => (candidate.relevance ?? 0) >= FIND_MIN_RELEVANCE);
559
+ lines.push(`jev_find: judged ${input.judged} of ${input.total} candidate file(s) via ${input.model} in ${input.elapsedMs}ms`);
560
+ const shown = strong.length > 0 ? strong.slice(0, input.limit) : input.ranked.slice(0, 3);
561
+ if (strong.length === 0) lines.push("No file is a confident match; the closest were:");
562
+ for (const candidate of shown) {
563
+ lines.push(`- ${pointer(candidate)} (${candidate.relevance === undefined ? "not judged" : candidate.relevance.toFixed(2)})`);
564
+ }
565
+ if (strong.length > shown.length) lines.push(`${strong.length - shown.length} more relevant file(s) past the limit.`);
566
+ }
567
+ if (input.total > MAX_FIND_CANDIDATES) {
568
+ lines.push(`${input.total - MAX_FIND_CANDIDATES} candidate(s) were not judged; narrow with \`pattern\`, \`glob\`, or \`path\`.`);
569
+ }
570
+ lines.push("Only pointers are returned; read the files you need.");
571
+ return lines.join("\n");
572
+ }
573
+
574
+ const JevFindParams = Type.Object({
575
+ question: Type.String({ description: "What you are looking for, in plain words (e.g. 'where is the retry backoff configured')." }),
576
+ pattern: Type.Optional(
577
+ Type.String({ description: "ripgrep regex to narrow candidates to files that match; omit to judge every listed file." }),
578
+ ),
579
+ glob: Type.Optional(Type.String({ description: "File glob filter, e.g. '*.ts' or 'src/**/*.md'." })),
580
+ path: Type.Optional(Type.String({ description: "Directory to search, inside the working directory (default: '.')." })),
581
+ ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive pattern." })),
582
+ limit: Type.Optional(Type.Number({ description: `Most results to return (default ${FIND_DEFAULT_LIMIT}).` })),
583
+ });
584
+
342
585
  // ---------------------------------------------------------------------------
343
586
  // Tool
344
587
  // ---------------------------------------------------------------------------
@@ -562,25 +805,147 @@ export default function jevAskToolExtension(pi: ExtensionAPI): void {
562
805
  },
563
806
  });
564
807
 
565
- // Offer the tool only while Jev can answer: a tool that always fails costs the agent a turn
566
- // every time it reaches for it. Checked before every run, so `/tokenin add` brings it back
808
+ pi.registerTool({
809
+ name: JEV_FIND_TOOL,
810
+ label: "Jev Find",
811
+ description: [
812
+ "Find which files answer a question without reading them: ripgrep gathers candidate files (those matching",
813
+ "`pattern`, or every file under `path` filtered by `glob`), and Jev judges each file's excerpt against",
814
+ "`question` in one parallel round trip. Returns ranked `path:lines (relevance)` pointers, never contents.",
815
+ `Judges up to ${MAX_FIND_CANDIDATES} files per call; respects .gitignore; secret-named files are never sent.`,
816
+ ].join(" "),
817
+ promptSnippet: "Find the files that answer a question: ripgrep candidates ranked by Jev, pointers only",
818
+ promptGuidelines: [
819
+ "When you look for code by what it does rather than by an exact identifier you already know, call jev_find first — before grep, find, ls, or reading candidate files. One call ranks up to 48 files in about a second and returns file:line pointers; then read only the top ones. Pass `question` in plain words; add `pattern` when you know a likely identifier, `glob`/`path` to scope it.",
820
+ "Use grep for an exact string or identifier you already know; use jev_find when you would otherwise grep several guesses or read files to see which one is relevant.",
821
+ ],
822
+ discovery: {
823
+ summary: "Semantic file finder: ripgrep candidates ranked by Jev relevance",
824
+ aliases: ["jevgrep", "find", "search", "locate"],
825
+ category: "Decisions",
826
+ },
827
+ parameters: JevFindParams,
828
+
829
+ async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
830
+ const text = (value: string, details: Record<string, unknown> = {}) => ({
831
+ content: [{ type: "text" as const, text: value }],
832
+ details,
833
+ });
834
+ const config = readJevAdvisoryConfig(getSettingsPath());
835
+ const route = config.routes.ask;
836
+ if (!route.enabled) {
837
+ return text(`jev_find is disabled (jevAdvisory.routes.ask.enabled is false in ${getSettingsPath()}).`);
838
+ }
839
+ const question = params.question?.trim() ?? "";
840
+ if (question === "") return text("jev_find needs a non-empty `question`.");
841
+
842
+ const rootArg = params.path?.trim() || ".";
843
+ const rootFull = resolve(ctx.cwd, rootArg);
844
+ const inside = relative(resolve(ctx.cwd), rootFull);
845
+ if (inside.startsWith("..") || isAbsolute(inside)) {
846
+ return text(`jev_find: ${rootArg} is outside the working directory.`);
847
+ }
848
+ const rg = await ensureTool("rg");
849
+ if (!rg) return text("jev_find needs ripgrep (rg), which is not available; use grep instead.");
850
+
851
+ const snippetBytes = Math.max(256, Math.floor((route.payloadBytes - FIND_QUESTION_RESERVE) / FIND_BATCH));
852
+ let found: { candidates: FindCandidate[]; total: number };
853
+ try {
854
+ found = await findCandidates(
855
+ {
856
+ rg,
857
+ cwd: ctx.cwd,
858
+ root: inside === "" ? "." : inside,
859
+ question,
860
+ pattern: params.pattern || undefined,
861
+ glob: params.glob || undefined,
862
+ ignoreCase: params.ignoreCase,
863
+ },
864
+ snippetBytes,
865
+ signal,
866
+ );
867
+ } catch (error) {
868
+ return text(`jev_find: ripgrep failed (${error instanceof Error ? error.message.split("\n")[0] : String(error)}).`);
869
+ }
870
+ const limit = Math.max(1, Math.floor(params.limit ?? FIND_DEFAULT_LIMIT));
871
+ const { candidates, total } = found;
872
+ if (candidates.length === 0) {
873
+ return text(renderFindResults({ ranked: [], total: 0, judged: 0, limit, model: config.model, elapsedMs: 0 }));
874
+ }
875
+
876
+ // Without Jev the candidates are still worth returning: the call is never wasted.
877
+ const connection = jevConnection(config, route);
878
+ const unreachable = await jevUnavailable(ctx, connection);
879
+ const batches: FindCandidate[][] = [];
880
+ for (let start = 0; start < candidates.length; start += FIND_BATCH) {
881
+ batches.push(candidates.slice(start, start + FIND_BATCH));
882
+ }
883
+ const started = Date.now();
884
+ const results = unreachable
885
+ ? []
886
+ : await Promise.all(
887
+ batches.map((batch) =>
888
+ askJevAnswers(ctx, connection, {
889
+ payload: fitFindPayload(question, batch, route.payloadBytes),
890
+ maxBytes: route.payloadBytes,
891
+ }),
892
+ ),
893
+ );
894
+ const elapsedMs = Date.now() - started;
895
+
896
+ const scored: Array<FindCandidate & { relevance?: number }> = [];
897
+ let judged = 0;
898
+ batches.forEach((batch, b) => {
899
+ const answers = results[b]?.answers ?? {};
900
+ batch.forEach((candidate, i) => {
901
+ const answer = answers[`f${i}`];
902
+ const relevance = isRecord(answer) && typeof answer.noul === "number" ? answer.noul : undefined;
903
+ if (relevance !== undefined) judged += 1;
904
+ scored.push({ ...candidate, ...(relevance === undefined ? {} : { relevance }) });
905
+ });
906
+ });
907
+ // Judged files by relevance; unjudged ones keep ripgrep's order behind them.
908
+ const ranked = judged > 0 ? [...scored].sort((a, b) => (b.relevance ?? -1) - (a.relevance ?? -1)) : scored;
909
+ const failure = unreachable ?? results.find((result) => result.failure)?.failure;
910
+
911
+ emitJevTelemetry(pi.events, "decision", {
912
+ route: "find",
913
+ outcome: judged > 0 ? "jev" : "fallback",
914
+ candidates: candidates.length,
915
+ confidence: confidenceBucket(ranked[0]?.relevance),
916
+ elapsedMs,
917
+ ...(judged > 0 ? {} : { reason: failure ?? "missing" }),
918
+ });
919
+ return text(
920
+ renderFindResults({ ranked, total, judged, limit, model: config.model, elapsedMs, failure }),
921
+ // Shape only, like ask_jev: details persist in the session.
922
+ { candidates: candidates.length, total, judged, elapsedMs, ...(failure ? { failure } : {}) },
923
+ );
924
+ },
925
+ });
926
+
927
+ // Offer the tools only while Jev can answer: a tool that always fails costs the agent a turn
928
+ // every time it reaches for it. Checked before every run, so `/tokenin add` brings them back
567
929
  // without a reload. Silent on purpose: the one warning comes from whichever Jev path first
568
930
  // needs the missing credential, not from every session of a user without a subscription.
569
931
  // Only a removal made here is ever undone, so a loadout the user trimmed stays trimmed.
570
- let hidden = false;
932
+ const jevTools = [ASK_JEV_TOOL, JEV_FIND_TOOL];
933
+ const hidden = new Set<string>();
571
934
  pi.on("before_agent_start", async (_event, ctx) => {
572
935
  const config = readJevAdvisoryConfig(getSettingsPath());
573
936
  const route = config.routes.ask;
574
937
  const offline = !route.enabled || (await jevUnavailable(ctx, jevConnection(config, route))) !== undefined;
575
938
  const active = pi.getActiveTools();
576
939
  if (offline) {
577
- if (active.includes(ASK_JEV_TOOL)) {
578
- pi.setActiveTools(active.filter((name) => name !== ASK_JEV_TOOL));
579
- hidden = true;
940
+ const dropping = jevTools.filter((name) => active.includes(name));
941
+ if (dropping.length > 0) {
942
+ pi.setActiveTools(active.filter((name) => !dropping.includes(name)));
943
+ for (const name of dropping) hidden.add(name);
580
944
  }
581
- } else if (hidden) {
582
- if (!active.includes(ASK_JEV_TOOL)) pi.setActiveTools([...active, ASK_JEV_TOOL]);
583
- hidden = false;
945
+ } else if (hidden.size > 0) {
946
+ const restoring = [...hidden].filter((name) => !active.includes(name));
947
+ if (restoring.length > 0) pi.setActiveTools([...active, ...restoring]);
948
+ hidden.clear();
584
949
  }
585
950
  return undefined;
586
951
  });
@@ -201,17 +201,19 @@ export function normalizePublicSubagentExecution<T extends PublicSubagentExecuti
201
201
  return { ok: false, error: "Structured single-child execution cannot be combined with workflow, workflowScript, or workflowScriptPath.", mode: "workflow" };
202
202
  }
203
203
  if (params.agent !== undefined || params.task !== undefined) {
204
- if (typeof params.agent !== "string" || !params.agent.trim()) {
205
- return { ok: false, error: "Structured single-child execution requires agent to be a non-empty string.", mode: "workflow" };
206
- }
207
204
  if (params.task !== undefined && typeof params.task !== "string") {
208
205
  return { ok: false, error: "Structured single-child task must be a string when provided.", mode: "workflow" };
209
206
  }
207
+ // A task without an agent is left for Jev subagent routing; the executor rejects it when routing cannot choose.
208
+ const agentless = params.agent === undefined && typeof params.task === "string" && params.task.trim() !== "";
209
+ if (!agentless && (typeof params.agent !== "string" || !params.agent.trim())) {
210
+ return { ok: false, error: "Structured single-child execution requires agent to be a non-empty string.", mode: "workflow" };
211
+ }
210
212
  return {
211
213
  ok: true,
212
214
  params: {
213
215
  ...params,
214
- agent: params.agent.trim(),
216
+ ...(typeof params.agent === "string" ? { agent: params.agent.trim() } : {}),
215
217
  output: params.output === undefined ? true : params.output,
216
218
  } as T,
217
219
  };
@@ -281,7 +281,7 @@ const ControlOverrides = Type.Object({
281
281
 
282
282
  const SubagentParamProperties = {
283
283
  agent: Type.Optional(Type.String({ description: "Agent for one-child execution, or target for agent management actions." })),
284
- task: Type.Optional(Type.String({ description: "Optional one-child task. Requires agent; cannot combine with action, workflowScript, or workflowScriptPath." })),
284
+ task: Type.Optional(Type.String({ description: "Optional one-child task. Requires agent unless Jev subagent routing is enabled, in which case agent may be omitted to let Jev choose by function; cannot combine with action, workflowScript, or workflowScriptPath." })),
285
285
  extensionBindings: Type.Optional(Type.Unsafe({ type: "object", maxProperties: 16, additionalProperties: true, description: "Namespaced, bounded plain-JSON metadata delivered only to the child runtime. Namespace keys use package.name/1 syntax." })),
286
286
  // Management action (when present, tool operates in management mode)
287
287
  action: Type.Optional(Type.String({ minLength: 1,
@@ -66,6 +66,7 @@ import { retainLiveForegroundNestedRoute } from "../../integrations/pi-web-sessi
66
66
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
67
67
  import { usageBudgetExceededMessage, usageBudgetState, validateUsageBudgetConfig } from "../shared/usage-budget.ts";
68
68
  import { intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
69
+ import { describeSubagentRouting, emitSubagentRoutingTelemetry, readSubagentRoutingSettings, routeSubagentLaunch, warnJevUnavailableOnce } from "../shared/jev-subagent-routing.ts";
69
70
  import { isAgentContract } from "../shared/agent-contract.ts";
70
71
  import { normalizeExtensionBindings, type ExtensionBindings } from "../shared/extension-bindings.ts";
71
72
  import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeSingleOutputOverride, outputPathMappingFromTask, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
@@ -6637,11 +6638,35 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
6637
6638
  if (cwdError) return buildRequestedModeError(effectiveParams, cwdError);
6638
6639
  const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
6639
6640
  const discovered = deps.discoverAgents(effectiveCwd, scope, requestParentModel?.provider);
6640
- const discoveredAgents = discovered.agents;
6641
+ let discoveredAgents = discovered.agents;
6641
6642
  const unknownAgentDiagnosticContext = diagnosticContextFromDiscovery(discovered, effectiveCwd, scope);
6642
6643
  const canonicalParams = canonicalizeExecutionParams(effectiveParams, discoveredAgents, discovered.agentDiagnostics, unknownAgentDiagnosticContext);
6643
6644
  if (canonicalParams.error) return buildRequestedModeError(effectiveParams, canonicalParams.error);
6644
6645
  effectiveParams = canonicalParams.params!;
6646
+ // Jev fills in what a public single-child call left open (agent, tools, model tier); opt-in.
6647
+ const routedSingle = publicExecution && !effectiveParams.chain?.length && !effectiveParams.tasks?.length && typeof effectiveParams.task === "string" && effectiveParams.task.trim() !== "";
6648
+ const jevRouting = routedSingle ? readSubagentRoutingSettings() : undefined;
6649
+ if (jevRouting) {
6650
+ const routed = await routeSubagentLaunch({
6651
+ task: effectiveParams.task!,
6652
+ agent: effectiveParams.agent,
6653
+ model: effectiveParams.model,
6654
+ agents: discoveredAgents,
6655
+ ceiling: intersectSubagentCapabilityCeilings(effectiveParams.capabilityCeiling, resolveCurrentSubagentCapabilityCeiling(resolveCurrentSessionId(ctx.sessionManager))),
6656
+ // pi-subagents' ModelRegistry typings predate `complete`; the host registry has it, and a missing one is an abstention.
6657
+ }, ctx as unknown as Parameters<typeof routeSubagentLaunch>[1], jevRouting);
6658
+ emitSubagentRoutingTelemetry(deps.pi.events, routed);
6659
+ if (routed.abstained === "no-credential" && ctx.hasUI) warnJevUnavailableOnce(ctx.ui, jevRouting.connection.provider);
6660
+ discoveredAgents = routed.agents;
6661
+ if (routed.agent !== undefined) effectiveParams = { ...effectiveParams, agent: routed.agent };
6662
+ const summary = describeSubagentRouting(routed, jevRouting.tiers);
6663
+ if (summary && ctx.hasUI) ctx.ui.notify(summary, "info");
6664
+ }
6665
+ if (routedSingle && !effectiveParams.agent) {
6666
+ return buildRequestedModeError(effectiveParams, jevRouting
6667
+ ? "Jev could not choose an agent for this task. Name one: call subagent with { action: \"list\", capabilities: true } to see them."
6668
+ : "Structured single-child execution requires agent; Jev subagent routing (jevAdvisory.routes.subagent.enabled) is off. Name an agent.");
6669
+ }
6645
6670
  if (effectiveParams.worktree === undefined && deps.config.worktree !== undefined) {
6646
6671
  effectiveParams = { ...effectiveParams, worktree: deps.config.worktree };
6647
6672
  }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Jev-assisted setup for a public single-child launch. Opt-in:
3
+ * `jevAdvisory.routes.subagent.enabled: true` in the agent settings.json.
4
+ *
5
+ * Jev only fills what the parent left open, and every answer can only narrow:
6
+ * 1. Agent: chosen by function when `agent` is omitted or generic (default "delegate"),
7
+ * from native, enabled agents the capability ceiling allows.
8
+ * 2. Tools: Jev may drop tools from the agent's declared allowlist for this task; it never adds one.
9
+ * 3. Model tier: simple | complex | reasoning, mapped through the user's
10
+ * `jevAdvisory.routes.subagent.tiers` (provider/id values), only when the parent passed no `model`.
11
+ *
12
+ * Every failure (no credential, timeout, malformed or low-confidence answer) leaves the launch
13
+ * exactly as requested. Launch validation, ceilings, and preflight remain authoritative.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import type { AgentConfig } from "../../agents/agents.ts";
18
+ import { getAgentDir } from "../../shared/utils.ts";
19
+ import { isAgentAllowedByCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "./capability-ceiling.ts";
20
+ import {
21
+ askJev,
22
+ buildConversation,
23
+ buildJevPayload,
24
+ emitJevTelemetry,
25
+ jevConnection,
26
+ readJevAdvisoryConfig,
27
+ type JevAbstainReason,
28
+ type JevConnection,
29
+ type JevQuestion,
30
+ type JevRuntime,
31
+ } from "../../../../jev/decisions.ts";
32
+
33
+ export { warnJevUnavailableOnce } from "../../../../jev/decisions.ts";
34
+
35
+ export const MODEL_TIERS = ["simple", "complex", "reasoning"] as const;
36
+ export type ModelTier = (typeof MODEL_TIERS)[number];
37
+
38
+ const KEEP = "keep";
39
+ const DEFAULT_GENERIC_AGENTS = ["delegate"];
40
+ /** Never offered for removal: skills load through read, and the rest are supervision plumbing. */
41
+ const PROTECTED_TOOLS = new Set(["read", "contact_supervisor", "intercom", "structured_output", "subagent", "subagent_supervisor"]);
42
+ // ponytail: tools past this cap are simply kept; raise it if agents declare long allowlists.
43
+ const MAX_TOOL_QUESTIONS = 10;
44
+ const DESCRIPTION_CHARS = 300;
45
+
46
+ const TIER_CRITERIA: Record<ModelTier, string> = {
47
+ simple: "Mechanical or lookup work: find, list, summarize, rename, run a command and report.",
48
+ complex: "Multi-step implementation or investigation across several files with ordinary judgment.",
49
+ reasoning: "Hard judgment: subtle debugging, architecture or security review, tricky algorithms, ambiguous trade-offs.",
50
+ };
51
+
52
+ export interface SubagentRoutingSettings {
53
+ connection: JevConnection;
54
+ maxBytes: number;
55
+ contextChars: number;
56
+ genericAgents: string[];
57
+ tiers: Partial<Record<ModelTier, string>>;
58
+ }
59
+
60
+ export interface SubagentRoutingInput {
61
+ task: string;
62
+ agent?: string;
63
+ model?: string;
64
+ agents: AgentConfig[];
65
+ ceiling?: ResolvedSubagentCapabilityCeiling;
66
+ }
67
+
68
+ export interface SubagentRoutingResult {
69
+ /** The agent to launch: Jev's pick, or the requested one. */
70
+ agent?: string;
71
+ /** The run's agent list, with the launched agent narrowed (tools) or re-modelled (tier). */
72
+ agents: AgentConfig[];
73
+ routedAgent?: string;
74
+ tier?: ModelTier;
75
+ droppedTools: string[];
76
+ /** First reason a question went unanswered, for telemetry and the missing-agent error. */
77
+ abstained?: JevAbstainReason;
78
+ elapsedMs: number;
79
+ questions: number;
80
+ }
81
+
82
+ type JevAsk = typeof askJev;
83
+
84
+ /** The route's settings, or undefined while it is off. Never throws. */
85
+ export function readSubagentRoutingSettings(settingsPath = path.join(getAgentDir(), "settings.json")): SubagentRoutingSettings | undefined {
86
+ const config = readJevAdvisoryConfig(settingsPath);
87
+ const route = config.routes.subagent;
88
+ if (!route.enabled) return undefined;
89
+ let raw: Record<string, unknown> = {};
90
+ try {
91
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf-8")) as { jevAdvisory?: { routes?: { subagent?: Record<string, unknown> } } };
92
+ raw = parsed?.jevAdvisory?.routes?.subagent ?? {};
93
+ } catch {
94
+ // readJevAdvisoryConfig already defaulted; extra fields just stay unset.
95
+ }
96
+ const tiers: Partial<Record<ModelTier, string>> = {};
97
+ const rawTiers = raw.tiers && typeof raw.tiers === "object" ? (raw.tiers as Record<string, unknown>) : {};
98
+ for (const tier of MODEL_TIERS) {
99
+ const model = rawTiers[tier];
100
+ if (typeof model === "string" && model.trim()) tiers[tier] = model.trim();
101
+ }
102
+ const generic = Array.isArray(raw.genericAgents) ? raw.genericAgents.filter((name): name is string => typeof name === "string" && name.trim() !== "") : DEFAULT_GENERIC_AGENTS;
103
+ return {
104
+ connection: jevConnection(config, route),
105
+ maxBytes: route.payloadBytes,
106
+ contextChars: route.contextChars,
107
+ genericAgents: generic,
108
+ tiers,
109
+ };
110
+ }
111
+
112
+ /** Agents Jev may pick: native, enabled, file-defined, and inside the capability ceiling. */
113
+ export function routableAgents(agents: readonly AgentConfig[], ceiling: ResolvedSubagentCapabilityCeiling | undefined): AgentConfig[] {
114
+ return agents.filter(
115
+ (agent) =>
116
+ agent.source !== "runtime"
117
+ && agent.disabled !== true
118
+ && agent.runner?.type !== "external-cli"
119
+ && agent.runner?.type !== "external-job"
120
+ && isAgentAllowedByCapabilityCeiling(agent.name, ceiling),
121
+ );
122
+ }
123
+
124
+ function droppableTools(agent: AgentConfig): string[] {
125
+ const excluded = new Set(agent.excludeTools ?? []);
126
+ return (agent.tools ?? [])
127
+ .filter((tool) => !PROTECTED_TOOLS.has(tool) && !excluded.has(tool) && !/[/\\]|\.(?:ts|js)$/.test(tool))
128
+ .slice(0, MAX_TOOL_QUESTIONS);
129
+ }
130
+
131
+ function toolKey(index: number): string {
132
+ return `tool_${index}`;
133
+ }
134
+
135
+ export async function routeSubagentLaunch(
136
+ input: SubagentRoutingInput,
137
+ ctx: JevRuntime,
138
+ settings: SubagentRoutingSettings,
139
+ ask: JevAsk = askJev,
140
+ ): Promise<SubagentRoutingResult> {
141
+ const result: SubagentRoutingResult = { agent: input.agent, agents: input.agents, droppedTools: [], elapsedMs: 0, questions: 0 };
142
+ const conversation = buildConversation(input.task.slice(0, settings.contextChars), [], { contextTurns: 1, contextChars: settings.contextChars });
143
+ const noteAbstain = (reason: JevAbstainReason | undefined) => {
144
+ if (reason && !result.abstained) result.abstained = reason;
145
+ };
146
+
147
+ // 1. Agent by function, only when the parent left it open.
148
+ const generic = input.agent !== undefined && settings.genericAgents.includes(input.agent);
149
+ if (input.agent === undefined || generic) {
150
+ const candidates = routableAgents(input.agents, input.ceiling).filter((agent) => agent.name !== input.agent);
151
+ if (candidates.length > 0) {
152
+ const criteria: Record<string, string> = {};
153
+ if (generic) criteria[KEEP] = `Keep the generic "${input.agent}" agent: no listed specialist fits this task better.`;
154
+ for (const agent of candidates) criteria[agent.name] = agent.description.slice(0, DESCRIPTION_CHARS);
155
+ const questions: Record<string, JevQuestion> = {
156
+ agent: {
157
+ question: "Which subagent's specialization best fits this delegated task?",
158
+ focus: "Judge by what the task needs done (read-only review, research, investigation, implementation), not by wording that names an agent.",
159
+ criteria,
160
+ },
161
+ };
162
+ const decision = await ask(ctx, settings.connection, {
163
+ payload: buildJevPayload(conversation, questions),
164
+ maxBytes: settings.maxBytes,
165
+ allowed: { agent: Object.keys(criteria) },
166
+ });
167
+ result.elapsedMs += decision.elapsedMs;
168
+ result.questions += 1;
169
+ const pick = decision.choices.agent?.choice;
170
+ if (pick && pick !== KEEP) {
171
+ result.agent = pick;
172
+ result.routedAgent = pick;
173
+ }
174
+ noteAbstain(decision.failure ?? decision.rejected.agent);
175
+ }
176
+ }
177
+
178
+ const config = result.agent === undefined ? undefined : input.agents.find((agent) => agent.name === result.agent);
179
+ if (!config) return result;
180
+
181
+ // 2 + 3. Tool narrowing and model tier, for the agent that will actually launch.
182
+ const tools = droppableTools(config);
183
+ const tierChoices = input.model === undefined ? MODEL_TIERS.filter((tier) => settings.tiers[tier] !== undefined) : [];
184
+ const questions: Record<string, JevQuestion> = {};
185
+ const allowed: Record<string, string[]> = {};
186
+ if (tierChoices.length > 1) {
187
+ questions.tier = {
188
+ question: "How demanding is this delegated task for the model that runs it?",
189
+ criteria: Object.fromEntries(tierChoices.map((tier) => [tier, TIER_CRITERIA[tier]])),
190
+ };
191
+ allowed.tier = [...tierChoices];
192
+ }
193
+ tools.forEach((tool, index) => {
194
+ questions[toolKey(index)] = {
195
+ question: `Does this delegated task need the "${tool}" tool?`,
196
+ focus: "Answer unneeded only when the task clearly cannot require it; when in doubt, it is needed.",
197
+ criteria: { needed: `The task may need ${tool}.`, unneeded: `The task clearly never needs ${tool}.` },
198
+ };
199
+ allowed[toolKey(index)] = ["needed", "unneeded"];
200
+ });
201
+ if (Object.keys(questions).length === 0) return result;
202
+
203
+ const decision = await ask(ctx, settings.connection, {
204
+ payload: buildJevPayload(conversation, questions),
205
+ maxBytes: settings.maxBytes,
206
+ allowed,
207
+ });
208
+ result.elapsedMs += decision.elapsedMs;
209
+ result.questions += Object.keys(questions).length;
210
+ noteAbstain(decision.failure);
211
+
212
+ const tier = decision.choices.tier?.choice as ModelTier | undefined;
213
+ const tierModel = tier ? settings.tiers[tier] : undefined;
214
+ if (tierModel) result.tier = tier;
215
+ // Dropping a tool needs a stated confidence: an unquantified "unneeded" keeps the tool.
216
+ result.droppedTools = tools.filter((_tool, index) => {
217
+ const choice = decision.choices[toolKey(index)];
218
+ return choice?.choice === "unneeded" && typeof choice.confidence === "number";
219
+ });
220
+ if (!tierModel && result.droppedTools.length === 0) return result;
221
+
222
+ const { modelProvider: _modelProvider, modelSource: _modelSource, ...base } = config;
223
+ const routed: AgentConfig = {
224
+ ...(tierModel ? base : config),
225
+ ...(tierModel ? { model: tierModel } : {}),
226
+ ...(result.droppedTools.length > 0 ? { excludeTools: [...(config.excludeTools ?? []), ...result.droppedTools] } : {}),
227
+ };
228
+ result.agents = input.agents.map((agent) => (agent === config ? routed : agent));
229
+ return result;
230
+ }
231
+
232
+ /** Content-free: shape and outcome only, never the task, descriptions, or answers. */
233
+ export function emitSubagentRoutingTelemetry(events: { emit(channel: string, data: unknown): void } | undefined, result: SubagentRoutingResult): void {
234
+ if (result.questions === 0) return;
235
+ const changed = Boolean(result.routedAgent || result.tier || result.droppedTools.length > 0);
236
+ emitJevTelemetry(events, "decision", {
237
+ route: "subagent",
238
+ outcome: changed ? "jev" : "fallback",
239
+ candidates: result.questions,
240
+ elapsedMs: result.elapsedMs,
241
+ ...(result.routedAgent ? { agent: result.routedAgent } : {}),
242
+ ...(result.tier ? { tier: result.tier } : {}),
243
+ dropped: result.droppedTools.length,
244
+ ...(result.abstained ? { reason: result.abstained } : {}),
245
+ });
246
+ }
247
+
248
+ /** One line for the user and the parent: what Jev set up, or nothing when it changed nothing. */
249
+ export function describeSubagentRouting(result: SubagentRoutingResult, tiers: Partial<Record<ModelTier, string>>): string | undefined {
250
+ const parts: string[] = [];
251
+ if (result.routedAgent) parts.push(`agent ${result.routedAgent}`);
252
+ if (result.tier) parts.push(`${result.tier} tier (${tiers[result.tier]})`);
253
+ if (result.droppedTools.length > 0) parts.push(`without ${result.droppedTools.join(", ")}`);
254
+ return parts.length > 0 ? `Jev set up this subagent: ${parts.join("; ")}.` : undefined;
255
+ }
@@ -0,0 +1,117 @@
1
+ import assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import type { AgentConfig } from "../../src/agents/agents.ts";
7
+ import {
8
+ readSubagentRoutingSettings,
9
+ routeSubagentLaunch,
10
+ type SubagentRoutingSettings,
11
+ } from "../../src/runs/shared/jev-subagent-routing.ts";
12
+
13
+ function agent(name: string, extra: Partial<AgentConfig> = {}): AgentConfig {
14
+ return { name, description: `${name} agent`, systemPromptMode: "append", inheritProjectContext: true, inheritGlobalContext: true, inheritSkills: true, systemPrompt: "", source: "builtin", filePath: `${name}.md`, ...extra } as AgentConfig;
15
+ }
16
+
17
+ const agents = [
18
+ agent("delegate"),
19
+ agent("reviewer", { tools: ["read", "grep"] }),
20
+ agent("worker", { tools: ["read", "bash", "edit", "write"], model: "anthropic/opus", modelProvider: "anthropic" }),
21
+ agent("secret", { disabled: true }),
22
+ agent("external", { runner: { type: "external-cli" } as AgentConfig["runner"] }),
23
+ ];
24
+
25
+ const settings: SubagentRoutingSettings = {
26
+ connection: { provider: "tokenin", model: "jev", timeoutMs: 1000, minConfidence: 0.6 },
27
+ maxBytes: 16_384,
28
+ contextChars: 4000,
29
+ genericAgents: ["delegate"],
30
+ tiers: { simple: "p/small", reasoning: "p/big" },
31
+ };
32
+
33
+ type Asked = { allowed: Record<string, readonly string[]> };
34
+ /** A fake Jev: answers each asked question from `answers`, records what was offered. */
35
+ function fakeAsk(answers: Record<string, { choice: string; confidence?: number }>, asked: Asked[] = []) {
36
+ return async (_ctx: unknown, _connection: unknown, request: Asked) => {
37
+ asked.push({ allowed: request.allowed });
38
+ const choices = Object.fromEntries(Object.keys(request.allowed).filter((q) => answers[q]).map((q) => [q, answers[q]!]));
39
+ return { choices, rejected: {}, elapsedMs: 5 };
40
+ };
41
+ }
42
+ const ctx = {} as never;
43
+
44
+ describe("Jev subagent routing", () => {
45
+ it("routes an agentless task, offering only enabled native agents inside the ceiling", async () => {
46
+ const asked: Asked[] = [];
47
+ const result = await routeSubagentLaunch(
48
+ { task: "fix the bug", agents, ceiling: { allowedAgents: ["delegate", "worker", "secret"], sources: ["test"] } as never },
49
+ ctx, settings, fakeAsk({ agent: { choice: "worker", confidence: 0.9 } }, asked) as never,
50
+ );
51
+ assert.equal(result.agent, "worker");
52
+ assert.deepEqual(asked[0]!.allowed.agent, ["delegate", "worker"]);
53
+ });
54
+
55
+ it("offers keep for a generic agent and never re-routes an explicit specialist", async () => {
56
+ const asked: Asked[] = [];
57
+ const kept = await routeSubagentLaunch({ task: "t", agent: "delegate", agents }, ctx, settings, fakeAsk({ agent: { choice: "keep", confidence: 0.9 } }, asked) as never);
58
+ assert.equal(kept.agent, "delegate");
59
+ assert.ok(asked[0]!.allowed.agent!.includes("keep"));
60
+ assert.ok(!asked[0]!.allowed.agent!.includes("delegate"));
61
+
62
+ const explicitAsked: Asked[] = [];
63
+ const explicit = await routeSubagentLaunch({ task: "t", agent: "reviewer", model: "x/y", agents }, ctx, settings, fakeAsk({}, explicitAsked) as never);
64
+ assert.equal(explicit.agent, "reviewer");
65
+ assert.ok(explicitAsked.every((ask) => !("agent" in ask.allowed)));
66
+ });
67
+
68
+ it("abstention leaves the launch as requested", async () => {
69
+ const result = await routeSubagentLaunch({ task: "t", agents }, ctx, settings, (async () => ({ choices: {}, rejected: { agent: "no-credential" }, failure: "no-credential", elapsedMs: 0 })) as never);
70
+ assert.equal(result.agent, undefined);
71
+ assert.equal(result.agents, agents);
72
+ assert.equal(result.abstained, "no-credential");
73
+ });
74
+
75
+ it("only drops declared, unprotected tools on a confident unneeded, and applies the tier model", async () => {
76
+ const asked: Asked[] = [];
77
+ const result = await routeSubagentLaunch(
78
+ { task: "summarize the README", agent: "worker", agents },
79
+ ctx, settings,
80
+ fakeAsk({ tier: { choice: "simple", confidence: 0.8 }, tool_0: { choice: "unneeded", confidence: 0.9 }, tool_1: { choice: "unneeded" }, tool_2: { choice: "needed", confidence: 0.9 } }, asked) as never,
81
+ );
82
+ // read is never offered: tool_0..2 are bash, edit, write.
83
+ assert.deepEqual(Object.keys(asked[0]!.allowed).sort(), ["tier", "tool_0", "tool_1", "tool_2"]);
84
+ assert.deepEqual(asked[0]!.allowed.tier, ["simple", "reasoning"]);
85
+ assert.deepEqual(result.droppedTools, ["bash"]);
86
+ const worker = result.agents.find((a) => a.name === "worker")!;
87
+ assert.deepEqual(worker.excludeTools, ["bash"]);
88
+ assert.equal(worker.model, "p/small");
89
+ assert.equal(worker.modelProvider, undefined);
90
+ assert.deepEqual(worker.tools, ["read", "bash", "edit", "write"]);
91
+ // The discovered list itself is untouched.
92
+ assert.equal(agents.find((a) => a.name === "worker")!.model, "anthropic/opus");
93
+ });
94
+
95
+ it("skips the tier question when the parent chose a model", async () => {
96
+ const asked: Asked[] = [];
97
+ const result = await routeSubagentLaunch({ task: "t", agent: "worker", model: "x/y", agents }, ctx, settings, fakeAsk({ tier: { choice: "simple", confidence: 0.9 } }, asked) as never);
98
+ assert.ok(!("tier" in asked[0]!.allowed));
99
+ assert.equal(result.tier, undefined);
100
+ });
101
+
102
+ it("reads settings: off by default, tiers and generic agents when enabled", () => {
103
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "jev-subagent-"));
104
+ try {
105
+ const file = path.join(dir, "settings.json");
106
+ fs.writeFileSync(file, JSON.stringify({ jevAdvisory: { routes: {} } }));
107
+ assert.equal(readSubagentRoutingSettings(file), undefined);
108
+ fs.writeFileSync(file, JSON.stringify({ jevAdvisory: { routes: { subagent: { enabled: true, tiers: { simple: " p/s ", bogus: "x" }, genericAgents: ["delegate", "general"] } } } }));
109
+ const read = readSubagentRoutingSettings(file)!;
110
+ assert.deepEqual(read.tiers, { simple: "p/s" });
111
+ assert.deepEqual(read.genericAgents, ["delegate", "general"]);
112
+ assert.equal(read.connection.timeoutMs, 5000);
113
+ } finally {
114
+ fs.rmSync(dir, { recursive: true, force: true });
115
+ }
116
+ });
117
+ });
@@ -19,6 +19,8 @@ describe("public subagent execution normalization", () => {
19
19
  output: true,
20
20
  },
21
21
  });
22
+ // Agentless tasks are left for Jev subagent routing; the executor rejects them when it cannot choose.
23
+ assert.deepEqual(normalizePublicSubagentExecution({ task: "work" }), { ok: true, params: { task: "work", output: true } });
22
24
  assert.deepEqual(normalizePublicSubagentExecution({ agent: "worker" }), {
23
25
  ok: true,
24
26
  params: {
@@ -171,7 +173,7 @@ describe("public subagent execution normalization", () => {
171
173
  { action: "reject-checkpoint", id: "run" },
172
174
  { agent: "" },
173
175
  { agent: 42 },
174
- { task: "work" },
176
+ { task: " " },
175
177
  { agent: "worker", task: 42 },
176
178
  { agent: "worker", workflowScript: "return 1" },
177
179
  { action: "status", task: "work" },
package/docs/settings.md CHANGED
@@ -50,7 +50,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
50
50
 
51
51
  The bundled `jev-advisory-routing` extension uses the Jev decisions model for bounded opt-in
52
52
  routing. The host-side routes stay off until they are enabled in `jevAdvisory`; the agent's own
53
- `ask` route is on by default. All three share one Jev provider/model pair:
53
+ `ask` route is on by default. All routes share one Jev provider/model pair:
54
54
 
55
55
  - `memory` — only after an explicit durable-memory cue (for example “the convention we
56
56
  decided” or “don't repeat the past failure”), Jev chooses one read-only local
@@ -67,6 +67,21 @@ routing. The host-side routes stay off until they are enabled in `jevAdvisory`;
67
67
  of the agent's loadout before each run (it returns after `/tokenin add`, no reload needed), and a call
68
68
  that slips through reads no file and runs no command.
69
69
 
70
+ The same route also offers `jev_find`, a file finder: ripgrep gathers up to 48 candidate files
71
+ (those matching `pattern`, or every file under `path` filtered by `glob`, ranked by question words),
72
+ Jev judges each file's best-matching lines against the agent's `question` in parallel batches of 16,
73
+ and the agent gets ranked `path:lines (relevance)` pointers — never file contents. It respects
74
+ `.gitignore`, stays inside the working directory, skips secret-named files, and still returns the
75
+ unranked candidates when Jev is unavailable.
76
+ - `subagent` — sets up a single-child `subagent` call from the parent agent (off by default). Jev only
77
+ fills what the parent left open, and can only narrow: (1) when `agent` is omitted or generic
78
+ (`genericAgents`, default `["delegate"]`), it picks an agent by function from enabled native agents
79
+ inside the capability ceiling; (2) it may drop tools from that agent's declared `tools` for this run
80
+ (never `read` or supervision tools, and never adds one); (3) when `tiers` is set and the parent
81
+ passed no `model`, it picks `simple`, `complex`, or `reasoning` and launches that tier's model. Jev
82
+ sees only the task and agent descriptions. Any abstention launches exactly what was asked; an
83
+ agentless task that Jev cannot route is rejected with a request to name an agent.
84
+
70
85
  | Setting | Type | Default | Description |
71
86
  |---------|------|---------|-------------|
72
87
  | `jevAdvisory.provider` | string | `"tokenin"` | Provider serving the Jev decisions deployment |
@@ -74,12 +89,15 @@ routing. The host-side routes stay off until they are enabled in `jevAdvisory`;
74
89
  | `jevAdvisory.baseUrl` | string | inherited | Base URL override; defaults to any registered model of `provider` |
75
90
  | `jevAdvisory.routes.memory.enabled` | boolean | `false` | Enable the memory-lookup route |
76
91
  | `jevAdvisory.routes.recommendations.enabled` | boolean | `false` | Enable skill/workflow and verification recommendations |
77
- | `jevAdvisory.routes.ask.enabled` | boolean | `true` | Offer the agent the `ask_jev` tool; set `false` to turn it off |
78
- | `<route>.timeoutMs` | number | `8000` | Route request timeout (ms); memory is capped at 750ms; `ask` defaults to `15000` |
92
+ | `jevAdvisory.routes.ask.enabled` | boolean | `true` | Offer the agent the `ask_jev` and `jev_find` tools; set `false` to turn both off |
93
+ | `jevAdvisory.routes.subagent.enabled` | boolean | `false` | Let Jev choose the agent, tools, and model tier of a subagent call |
94
+ | `jevAdvisory.routes.subagent.tiers` | object | unset | `{ "simple"?, "complex"?, "reasoning"? }` → `provider/id` model; tier routing needs at least two |
95
+ | `jevAdvisory.routes.subagent.genericAgents` | string[] | `["delegate"]` | Agents Jev may replace with a specialist |
96
+ | `<route>.timeoutMs` | number | `8000` | Route request timeout (ms); memory is capped at 750ms; `ask` defaults to `15000`, `subagent` to `5000` |
79
97
  | `<route>.minConfidence` | number | `0.6` | Below this Jev confidence the route abstains |
80
98
  | `<route>.contextTurns` | number | `4` | Prior user turns sent as recommendation context; memory sends only the current bounded prompt |
81
99
  | `<route>.contextChars` | number | `4000` | Character budget for recommendation context |
82
- | `<route>.payloadBytes` | number | `8192` | Hard cap on the serialized decision request; `ask` defaults to `32768` |
100
+ | `<route>.payloadBytes` | number | `8192` | Hard cap on the serialized decision request; `ask` defaults to `32768`, `subagent` to `16384` |
83
101
 
84
102
  `ask` ignores `minConfidence`, `contextTurns`, and `contextChars`: it returns every confidence to the
85
103
  agent, and its context is whatever the agent put in the request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.34",
3
+ "version": "0.13.35",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "engines": {