@dreb/coding-agent 2.17.0 → 2.19.0

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.
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
+ import { complete } from "@dreb/ai";
6
7
  import { Text } from "@dreb/tui";
7
8
  import { Type } from "@sinclair/typebox";
8
9
  import { CONFIG_DIR_NAME, getPackageDir, getSubagentSessionsDir } from "../../config.js";
@@ -108,6 +109,31 @@ function loadAgentsFromDir(dir, agents) {
108
109
  // and process.argv[1] may return corrupted or truncated data.
109
110
  const DREB_SCRIPT = process.argv[1] || "dreb";
110
111
  const NODE_EXEC = process.execPath;
112
+ // Tools that must never be available to subagents — wait (subagents should
113
+ // never no-op; they have a task to complete) and subagent (no recursive spawning).
114
+ const SUBAGENT_EXCLUDED_TOOLS = ["wait", "subagent"];
115
+ // Default standard tools for subagents when no tools are specified in the agent
116
+ // definition. This is the set passed via --tools to the child process.
117
+ //
118
+ // NOTE: Always-active tools (search, skill, tasks_update, suggest_next) are NOT
119
+ // listed here — the child process adds them unconditionally regardless of --tools.
120
+ // Internal tools (tmp_read) are also excluded.
121
+ const SUBAGENT_DEFAULT_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "web_search", "web_fetch"];
122
+ /**
123
+ * Filter a comma-separated tools string, removing any tools in SUBAGENT_EXCLUDED_TOOLS.
124
+ * Returns the filtered tools as a comma-separated string (always non-empty — falls
125
+ * back to SUBAGENT_DEFAULT_TOOLS if all specified tools were excluded).
126
+ */
127
+ export function filterSubagentTools(tools) {
128
+ if (!tools)
129
+ return SUBAGENT_DEFAULT_TOOLS.join(",");
130
+ const filtered = tools
131
+ .split(",")
132
+ .map((t) => t.trim())
133
+ .filter((t) => !SUBAGENT_EXCLUDED_TOOLS.includes(t))
134
+ .join(",");
135
+ return filtered || SUBAGENT_DEFAULT_TOOLS.join(",");
136
+ }
111
137
  // TODO: Support PATH-based binary discovery.
112
138
  // Currently returns the captured argv[1].
113
139
  function findDrebBinary() {
@@ -147,9 +173,9 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
147
173
  args.push("--provider", parentProvider);
148
174
  }
149
175
  }
150
- if (agentConfig.tools) {
151
- args.push("--tools", agentConfig.tools);
152
- }
176
+ // Always pass --tools to ensure wait/subagent are excluded from child processes.
177
+ // filterSubagentTools always returns a non-empty string.
178
+ args.push("--tools", filterSubagentTools(agentConfig.tools));
153
179
  if (agentConfig.systemPrompt) {
154
180
  args.push("--append-system-prompt", agentConfig.systemPrompt);
155
181
  }
@@ -433,6 +459,173 @@ export function resolveModelStringSingle(modelStr, parentProvider, registry) {
433
459
  }
434
460
  return { ok: true, modelId: resolved.model.id, provider: resolved.model.provider };
435
461
  }
462
+ function compactErrorReason(reason) {
463
+ const singleLine = reason.replace(/\s+/g, " ").trim();
464
+ return singleLine.length > 180 ? `${singleLine.slice(0, 177)}...` : singleLine || "unknown error";
465
+ }
466
+ function reasonFromRuntimeError(value) {
467
+ if (value instanceof Error)
468
+ return value.message;
469
+ if (typeof value === "string")
470
+ return value;
471
+ if (value && typeof value === "object") {
472
+ const maybeMessage = value;
473
+ if (typeof maybeMessage.errorMessage === "string")
474
+ return maybeMessage.errorMessage;
475
+ if (typeof maybeMessage.message === "string")
476
+ return maybeMessage.message;
477
+ }
478
+ return String(value);
479
+ }
480
+ export function isRuntimeUnavailableError(value) {
481
+ if (value instanceof Error || typeof value === "string")
482
+ return true;
483
+ if (value && typeof value === "object") {
484
+ const maybeMessage = value;
485
+ return maybeMessage.stopReason === "error" || maybeMessage.stopReason === "aborted";
486
+ }
487
+ return false;
488
+ }
489
+ function makeProbeSignal(parentSignal, timeoutMs) {
490
+ const controller = new AbortController();
491
+ const timeoutError = new Error(`Model availability probe timed out after ${timeoutMs}ms`);
492
+ let timeout;
493
+ const timeoutPromise = new Promise((_, reject) => {
494
+ timeout = setTimeout(() => {
495
+ controller.abort(timeoutError);
496
+ reject(timeoutError);
497
+ }, timeoutMs);
498
+ });
499
+ const parentAbortHandler = () => controller.abort(parentSignal?.reason);
500
+ parentSignal?.addEventListener("abort", parentAbortHandler, { once: true });
501
+ if (parentSignal?.aborted)
502
+ controller.abort(parentSignal.reason);
503
+ return {
504
+ signal: controller.signal,
505
+ timeoutPromise,
506
+ cleanup: () => {
507
+ clearTimeout(timeout);
508
+ parentSignal?.removeEventListener("abort", parentAbortHandler);
509
+ },
510
+ };
511
+ }
512
+ export async function probeModelAvailability(model, options = {}) {
513
+ const { signal, registry, timeoutMs = 10_000 } = options;
514
+ if (signal?.aborted)
515
+ return { ok: false, reason: "Aborted before spawn", aborted: true };
516
+ const probeSignal = makeProbeSignal(signal, timeoutMs);
517
+ try {
518
+ const context = {
519
+ systemPrompt: "You are a model availability probe. Reply briefly.",
520
+ messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
521
+ };
522
+ const apiKey = await Promise.race([
523
+ registry ? registry.getApiKey(model) : Promise.resolve(undefined),
524
+ probeSignal.timeoutPromise,
525
+ ]);
526
+ if (signal?.aborted)
527
+ return { ok: false, reason: "Aborted before spawn", aborted: true };
528
+ const result = await Promise.race([
529
+ complete(model, context, {
530
+ apiKey,
531
+ maxRetryDelayMs: 0,
532
+ maxTokens: 1,
533
+ signal: probeSignal.signal,
534
+ }),
535
+ probeSignal.timeoutPromise,
536
+ ]);
537
+ if (signal?.aborted)
538
+ return { ok: false, reason: "Aborted before spawn", aborted: true };
539
+ if (isRuntimeUnavailableError(result)) {
540
+ return { ok: false, reason: compactErrorReason(reasonFromRuntimeError(result)) };
541
+ }
542
+ return { ok: true };
543
+ }
544
+ catch (err) {
545
+ if (signal?.aborted)
546
+ return { ok: false, reason: "Aborted before spawn", aborted: true };
547
+ return { ok: false, reason: compactErrorReason(reasonFromRuntimeError(err)) };
548
+ }
549
+ finally {
550
+ probeSignal.cleanup();
551
+ }
552
+ }
553
+ export async function resolveModelForSubagentSpawn(models, parentProvider, registry, parentModel, signal) {
554
+ if (signal?.aborted)
555
+ return { ok: false, error: "Aborted before spawn", skippedModels: [] };
556
+ // Runtime probing only applies to agent definition fallback lists. Single
557
+ // models, per-invocation overrides, and registry-less environments keep the
558
+ // existing spawn-time resolution behavior exactly.
559
+ if (!Array.isArray(models) || !registry) {
560
+ const resolved = resolveModelWithFallbacks(models, parentProvider, registry, parentModel);
561
+ return { ...resolved, skippedModels: [] };
562
+ }
563
+ const skippedModels = [];
564
+ let lastError = "";
565
+ for (const modelStr of models) {
566
+ if (signal?.aborted)
567
+ return { ok: false, error: "Aborted before spawn", skippedModels };
568
+ const resolved = resolveModelStringSingle(modelStr, parentProvider, registry);
569
+ if (!resolved.ok) {
570
+ lastError = resolved.error;
571
+ const reason = compactErrorReason(resolved.error);
572
+ skippedModels.push({ model: modelStr, reason });
573
+ console.error(`[subagent] Model "${modelStr}" unavailable (${reason}). Trying next fallback...`);
574
+ continue;
575
+ }
576
+ const modelObj = resolved.provider ? registry.find(resolved.provider, resolved.modelId) : undefined;
577
+ if (modelObj) {
578
+ const probe = await probeModelAvailability(modelObj, { signal, registry });
579
+ if (!probe.ok && probe.aborted) {
580
+ return { ok: false, error: "Aborted before spawn", skippedModels };
581
+ }
582
+ if (signal?.aborted)
583
+ return { ok: false, error: "Aborted before spawn", skippedModels };
584
+ if (!probe.ok) {
585
+ lastError = probe.reason;
586
+ skippedModels.push({ model: modelStr, reason: probe.reason });
587
+ console.error(`[subagent] Model "${modelStr}" failed probe (${probe.reason}). Trying next fallback...`);
588
+ continue;
589
+ }
590
+ }
591
+ console.error(`[subagent] Using model "${resolved.modelId}" for subagent.`);
592
+ return { ...resolved, skippedModels };
593
+ }
594
+ if (signal?.aborted)
595
+ return { ok: false, error: "Aborted before spawn", skippedModels };
596
+ if (parentModel) {
597
+ const parentResolved = resolveModelStringSingle(parentModel, parentProvider, registry);
598
+ if (parentResolved.ok) {
599
+ const warning = `Agent preferred models were unavailable. Falling back to parent model "${parentResolved.modelId}".`;
600
+ console.error(`[subagent] ${warning}`);
601
+ return { ...parentResolved, warning, skippedModels };
602
+ }
603
+ lastError = parentResolved.error;
604
+ }
605
+ return {
606
+ ok: false,
607
+ skippedModels,
608
+ error: `None of the fallback models passed availability checks: ${[
609
+ ...models,
610
+ ...(parentModel ? [parentModel] : []),
611
+ ].join(", ")}. Last error: ${lastError || "all probes failed"}`,
612
+ };
613
+ }
614
+ export function formatModelFallbackSummary(skippedModels, selectedModel) {
615
+ if (skippedModels.length === 0)
616
+ return undefined;
617
+ const skipped = skippedModels.map((s) => `- ${s.model}: ${s.reason}`).join("\n");
618
+ return `[MODEL FALLBACK: skipped ${skippedModels.length} unavailable model(s); using "${selectedModel ?? "unknown"}".]\n${skipped}`;
619
+ }
620
+ export function prependModelFallbackSummary(output, skippedModels, selectedModel) {
621
+ const fallbackSummary = formatModelFallbackSummary(skippedModels, selectedModel);
622
+ return fallbackSummary ? `${fallbackSummary}\n\n${output}` : output;
623
+ }
624
+ function formatSkippedModelFailureDetails(skippedModels) {
625
+ if (skippedModels.length === 0)
626
+ return undefined;
627
+ return `Skipped models:\n${skippedModels.map((s) => `- ${s.model}: ${s.reason}`).join("\n")}`;
628
+ }
436
629
  const MAX_PARALLEL_TASKS = 8;
437
630
  const MAX_CONCURRENCY = 4;
438
631
  const MAX_TASK_LENGTH = 32_768; // 32 KB — prevent E2BIG from oversized argv
@@ -474,7 +667,7 @@ function clampCwd(defaultCwd, itemCwd) {
474
667
  }
475
668
  return { ok: true, cwd: resolved };
476
669
  }
477
- async function executeSingle(agents, agentName, task, cwd, signal, onProgress, modelOverride, parentProvider, registry, sessionDir, parentModel) {
670
+ export async function executeSingle(agents, agentName, task, cwd, signal, onProgress, modelOverride, parentProvider, registry, sessionDir, parentModel) {
478
671
  const name = agentName || DEFAULT_AGENT;
479
672
  const config = agents.get(name);
480
673
  if (!config) {
@@ -504,20 +697,25 @@ async function executeSingle(agents, agentName, task, cwd, signal, onProgress, m
504
697
  let effectiveConfig = modelOverride ? { ...config, model: modelOverride } : config;
505
698
  let resolvedProvider = parentProvider;
506
699
  let warning;
700
+ let skippedModels = [];
507
701
  // Resolve and validate the model against the registry before spawning.
508
702
  // This catches typos and invalid model names immediately instead of failing
509
703
  // silently in the child process. Also passes the canonical model ID to the
510
- // child, avoiding fuzzy matching entirely.
704
+ // child, avoiding fuzzy matching entirely. Agent definition fallback lists get
705
+ // an additional best-effort 1-token probe before spawn so runtime-unavailable
706
+ // models are skipped before committing to a child process.
511
707
  if (modelSpec) {
512
- const resolved = resolveModelWithFallbacks(modelSpec, parentProvider, registry, parentModel);
708
+ const resolved = await resolveModelForSubagentSpawn(modelSpec, parentProvider, registry, parentModel, signal);
709
+ skippedModels = resolved.skippedModels;
513
710
  if (!resolved.ok) {
711
+ const skippedDetails = formatSkippedModelFailureDetails(skippedModels);
514
712
  return {
515
713
  agent: name,
516
714
  task,
517
715
  exitCode: 1,
518
716
  output: "",
519
717
  stderr: "",
520
- errorMessage: resolved.error,
718
+ errorMessage: skippedDetails ? `${resolved.error}\n\n${skippedDetails}` : resolved.error,
521
719
  };
522
720
  }
523
721
  effectiveConfig = { ...effectiveConfig, model: resolved.modelId };
@@ -528,6 +726,7 @@ async function executeSingle(agents, agentName, task, cwd, signal, onProgress, m
528
726
  }
529
727
  onProgress?.(`Running ${name} agent...`);
530
728
  const result = await spawnSubagent(effectiveConfig, task, cwd, signal, onProgress, resolvedProvider, sessionDir);
729
+ result.output = prependModelFallbackSummary(result.output, skippedModels, result.model ?? effectiveConfig.model?.toString());
531
730
  if (warning) {
532
731
  result.output = `[WARNING: ${warning}]\n\n${result.output}`;
533
732
  }