zimejin commited on
Commit
a4c19ea
·
1 Parent(s): a01038f

Cursor: Apply local changes for cloud agent

Browse files
docs/DISCOVERY.md CHANGED
@@ -74,11 +74,25 @@ SearXNG, Fantastic Jobs, Firecrawl Search, and Hirebase posted dates all pass th
74
  - **Demote** weak/unclear regional hits pre-LLM into the score shortlist; **hard-drop** only high-confidence onsite/hybrid/inaccessible rows with rich body (≥400 chars). No “thinner than usual” stigma for intentional FJ + Firecrawl + SearXNG stacks.
75
  - Hard WFA accessibility gate post-score.
76
 
77
- ## Search angles (reasoned vs deterministic)
78
 
79
- `discover_jobs` is a thick tool: one agent call runs several internal angles. When the planner LLM can author `searchAngles`, those become hypotheses (ATS-first, work-from-anywhere, visa, skill adjacency). **Angle 0 always searches the tool’s primary intent**; later angles consume the hypotheses. `focus` compiles into query bias (`forceVisaQuery`, ATS `angleIndex` 0, WFA phrasing, semantic broadening) — it is not decorative.
80
 
81
- When the planner LLM is down (common on unpublished Groq slugs or exhausted OpenRouter), the tool still runs **built-in** ATS-first + WFA + visa (if the profile wants sponsorship) + skill-adjacency angles. Search stays thorough; it does not collapse to a single deterministic query.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  ## Seen listings (TTL by source)
84
 
 
74
  - **Demote** weak/unclear regional hits pre-LLM into the score shortlist; **hard-drop** only high-confidence onsite/hybrid/inaccessible rows with rich body (≥400 chars). No “thinner than usual” stigma for intentional FJ + Firecrawl + SearXNG stacks.
75
  - Hard WFA accessibility gate post-score.
76
 
77
+ ## Nested research (where / how / execute)
78
 
79
+ Discovery is three layers — not a playlist of queries:
80
 
81
+ | Layer | Question | Owner |
82
+ |-------|----------|--------|
83
+ | **Outer** | Where should I look? | Chat agent + outcome policy — research *avenues* |
84
+ | **Inner** | How should I look *here*? | One `discover_jobs` call — Search → inspect → reflect → adapt angles |
85
+ | **Provider** | How do I execute reliably? | Dual planners compile intent/focus/pivot → Fantastic / Firecrawl / SearXNG (etc.) — agents never author provider query strings |
86
+
87
+ **One `discover_jobs` = one avenue.** Optional `searchAngles` are hypotheses for the inner loop only. After each angle, structured `AngleObservation`s feed an LLM adapter (`planNextSearchAngle`); on failure, deterministic pivots remain. When the avenue stops, an `AvenueObservation` (`strategyTrace`, quality, geography/role-fit signals, promising terms) crosses to the outer loop.
88
+
89
+ **Outer reflection** compares `avenueSummaries` and may start a different avenue (capped by `DISCOVER_OUTER_STRATEGY_RETRIES`, default 2) or present findings. Outcome policy may also nudge `recency="48h"` when listings were found but none scored on a 24h window.
90
+
91
+ **Hard bounds** (env-overridable): `PERSIST_MAX_PIVOTS`, `PERSIST_MAX_MINUTES`, `PERSIST_TARGET_STRONG`, `PERSIST_DIMINISHING`, `DISCOVER_OUTER_STRATEGY_RETRIES`, `DISCOVER_INNER_PLANNER_TIMEOUT_MS`. Neither loop is unconstrained.
92
+
93
+ ### Legacy note (searchAngles)
94
+
95
+ Angle 0 still searches the tool’s primary `intent`. `focus` compiles into query bias (`forceVisaQuery`, ATS `angleIndex`, WFA phrasing, semantic broadening). When the planner LLM is down, built-in ATS-first + WFA + visa + skill-adjacency seeds keep the avenue thorough; they are candidates, not a forced playlist.
96
 
97
  ## Seen listings (TTL by source)
98
 
lib/agent-chat/autonomy-policy.selftest.ts CHANGED
@@ -3,11 +3,14 @@ import assert from "node:assert/strict";
3
  import {
4
  BROADEN_RECENCY_USER_LINE,
5
  broadenRecencyUserLine,
 
6
  evaluateDiscoverOutcomePolicy,
7
  humanizeToolError,
8
  INFRA_FETCH_FAILED_MESSAGE,
9
  isColloquialResumeMessage,
10
  PAGE_FETCH_FAILED_MESSAGE,
 
 
11
  shouldTreatAsHardSearchFailure,
12
  } from "@/lib/agent-chat/autonomy-policy";
13
  import { shouldRefundDiscoverCredits, shouldRefundTimedOutDiscoverWithoutRun } from "@/lib/digest-admin-errors";
@@ -241,6 +244,56 @@ function testOutcomePolicy(): void {
241
  }),
242
  { action: "none" },
243
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  }
245
 
246
  function testRefundDiscoverCredits(): void {
 
3
  import {
4
  BROADEN_RECENCY_USER_LINE,
5
  broadenRecencyUserLine,
6
+ discoverOuterStrategyRetries,
7
  evaluateDiscoverOutcomePolicy,
8
  humanizeToolError,
9
  INFRA_FETCH_FAILED_MESSAGE,
10
  isColloquialResumeMessage,
11
  PAGE_FETCH_FAILED_MESSAGE,
12
+ RETRY_STRATEGY_USER_LINE,
13
+ retryStrategyUserLine,
14
  shouldTreatAsHardSearchFailure,
15
  } from "@/lib/agent-chat/autonomy-policy";
16
  import { shouldRefundDiscoverCredits, shouldRefundTimedOutDiscoverWithoutRun } from "@/lib/digest-admin-errors";
 
244
  }),
245
  { action: "none" },
246
  );
247
+
248
+ // Avenue exhausted + weak strong yield → outer strategy retry
249
+ assert.deepEqual(
250
+ evaluateDiscoverOutcomePolicy({
251
+ totalDiscovered: 40,
252
+ totalScored: 2,
253
+ strongMatchCount: 1,
254
+ stoppedBecause: "diminishing",
255
+ internalPivotsExhausted: true,
256
+ toolCallsUsed: 1,
257
+ maxToolCalls: 10,
258
+ autoCycleCount: 0,
259
+ currentRecency: "48h",
260
+ }),
261
+ { action: "retry_strategy" },
262
+ );
263
+ assert.ok(RETRY_STRATEGY_USER_LINE.includes("fundamentally different"));
264
+ assert.ok(retryStrategyUserLine("run-xyz").includes("seedFromRunId=\"run-xyz\""));
265
+ assert.ok(discoverOuterStrategyRetries() >= 0);
266
+
267
+ // Target strong reached → no strategy retry
268
+ assert.deepEqual(
269
+ evaluateDiscoverOutcomePolicy({
270
+ totalDiscovered: 40,
271
+ totalScored: 6,
272
+ strongMatchCount: 6,
273
+ stoppedBecause: "target_strong",
274
+ toolCallsUsed: 1,
275
+ maxToolCalls: 10,
276
+ autoCycleCount: 0,
277
+ currentRecency: "48h",
278
+ }),
279
+ { action: "none" },
280
+ );
281
+
282
+ // Outer retry budget exhausted
283
+ assert.deepEqual(
284
+ evaluateDiscoverOutcomePolicy({
285
+ totalDiscovered: 40,
286
+ totalScored: 1,
287
+ strongMatchCount: 0,
288
+ stoppedBecause: "max_pivots",
289
+ internalPivotsExhausted: true,
290
+ toolCallsUsed: 2,
291
+ maxToolCalls: 10,
292
+ autoCycleCount: discoverOuterStrategyRetries(),
293
+ currentRecency: "48h",
294
+ }),
295
+ { action: "none" },
296
+ );
297
  }
298
 
299
  function testRefundDiscoverCredits(): void {
lib/agent-chat/autonomy-policy.ts CHANGED
@@ -21,6 +21,29 @@ export function broadenRecencyUserLine(seedFromRunId?: string): string {
21
  );
22
  }
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  export const INFRA_FETCH_FAILED_MESSAGE =
25
  "Background search couldn't start. Continuing with results I already have.";
26
 
@@ -74,38 +97,48 @@ export function shouldTreatAsHardSearchFailure(ctx: SoftFailureContext): boolean
74
  export type DiscoverOutcomeInput = {
75
  totalDiscovered: number;
76
  totalScored: number;
 
 
77
  timedOut?: boolean;
78
  backgroundContinued?: boolean;
79
  stoppedBecause?: string;
 
80
  toolCallsUsed: number;
81
  maxToolCalls: number;
82
  autoCycleCount: number;
83
  /** Recency already used for this discover; 48h is the ceiling — do not broaden again. */
84
  currentRecency?: string;
 
 
85
  };
86
 
87
  export type DiscoverOutcomeAction =
88
  | { action: "none" }
89
  | { action: "broaden_recency" }
 
90
  | { action: "await_resume" };
91
 
92
  /**
93
  * Server-side outcome policy after discover_jobs.
94
  * - discovered>0 / scored=0 on 24h → one 48h recency retry (budget permitting)
95
- * - already 48h (or unset, which normalizes to default 48h) → none
 
96
  * - timeout + background continued → pause for durable resume (not a hard error)
97
  */
98
  export function evaluateDiscoverOutcomePolicy(input: DiscoverOutcomeInput): DiscoverOutcomeAction {
99
  const {
100
  totalDiscovered,
101
  totalScored,
 
102
  timedOut,
103
  backgroundContinued,
104
  stoppedBecause,
 
105
  toolCallsUsed,
106
  maxToolCalls,
107
  autoCycleCount,
108
  currentRecency,
 
109
  } = input;
110
 
111
  if (timedOut && backgroundContinued) {
@@ -128,5 +161,26 @@ export function evaluateDiscoverOutcomePolicy(input: DiscoverOutcomeInput): Disc
128
  return { action: "broaden_recency" };
129
  }
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  return { action: "none" };
132
  }
 
21
  );
22
  }
23
 
24
+ export const RETRY_STRATEGY_USER_LINE =
25
+ "[System] Outcome policy: the previous discover_jobs avenue exhausted with weak yield. " +
26
+ "Compare avenueSummaries / avenueObservation / lastReflection. " +
27
+ "Call discover_jobs once more with a fundamentally different approach (new avenue intent), not a clone of the last intent. " +
28
+ "Optional searchAngles are hypotheses for the inner loop only. Prefer seedFromRunId when available. " +
29
+ "Do not write raw provider queries.";
30
+
31
+ /** Build the outer-avenue retry nudge, optionally pinning seedFromRunId. */
32
+ export function retryStrategyUserLine(seedFromRunId?: string): string {
33
+ if (!seedFromRunId?.trim()) return RETRY_STRATEGY_USER_LINE;
34
+ return (
35
+ `${RETRY_STRATEGY_USER_LINE} ` +
36
+ `Pass seedFromRunId=\"${seedFromRunId.trim()}\" so work continues on the existing durable run.`
37
+ );
38
+ }
39
+
40
+ /** Max outer avenue switches per turn (env DISCOVER_OUTER_STRATEGY_RETRIES, default 2). */
41
+ export function discoverOuterStrategyRetries(): number {
42
+ const n = parseInt(String(process.env.DISCOVER_OUTER_STRATEGY_RETRIES ?? "2").trim(), 10);
43
+ if (!Number.isFinite(n)) return 2;
44
+ return Math.min(4, Math.max(0, n));
45
+ }
46
+
47
  export const INFRA_FETCH_FAILED_MESSAGE =
48
  "Background search couldn't start. Continuing with results I already have.";
49
 
 
97
  export type DiscoverOutcomeInput = {
98
  totalDiscovered: number;
99
  totalScored: number;
100
+ /** Strong matches from this avenue (preferred over totalScored for strategy retry). */
101
+ strongMatchCount?: number;
102
  timedOut?: boolean;
103
  backgroundContinued?: boolean;
104
  stoppedBecause?: string;
105
+ internalPivotsExhausted?: boolean;
106
  toolCallsUsed: number;
107
  maxToolCalls: number;
108
  autoCycleCount: number;
109
  /** Recency already used for this discover; 48h is the ceiling — do not broaden again. */
110
  currentRecency?: string;
111
+ /** Strong-match bar for considering an avenue successful (default 5). */
112
+ targetStrong?: number;
113
  };
114
 
115
  export type DiscoverOutcomeAction =
116
  | { action: "none" }
117
  | { action: "broaden_recency" }
118
+ | { action: "retry_strategy" }
119
  | { action: "await_resume" };
120
 
121
  /**
122
  * Server-side outcome policy after discover_jobs.
123
  * - discovered>0 / scored=0 on 24h → one 48h recency retry (budget permitting)
124
+ * - already 48h (or unset, which normalizes to default 48h) → none for recency
125
+ * - avenue exhausted + weak strong yield → capped outer avenue switch (retry_strategy)
126
  * - timeout + background continued → pause for durable resume (not a hard error)
127
  */
128
  export function evaluateDiscoverOutcomePolicy(input: DiscoverOutcomeInput): DiscoverOutcomeAction {
129
  const {
130
  totalDiscovered,
131
  totalScored,
132
+ strongMatchCount,
133
  timedOut,
134
  backgroundContinued,
135
  stoppedBecause,
136
+ internalPivotsExhausted,
137
  toolCallsUsed,
138
  maxToolCalls,
139
  autoCycleCount,
140
  currentRecency,
141
+ targetStrong = 5,
142
  } = input;
143
 
144
  if (timedOut && backgroundContinued) {
 
161
  return { action: "broaden_recency" };
162
  }
163
 
164
+ const strong =
165
+ typeof strongMatchCount === "number" && Number.isFinite(strongMatchCount) ?
166
+ Math.max(0, strongMatchCount)
167
+ : 0;
168
+ const avenueExhausted =
169
+ internalPivotsExhausted === true ||
170
+ stoppedBecause === "max_pivots" ||
171
+ stoppedBecause === "diminishing" ||
172
+ stoppedBecause === "time";
173
+ const outerCap = discoverOuterStrategyRetries();
174
+ if (
175
+ avenueExhausted &&
176
+ strong < targetStrong &&
177
+ autoCycleCount < outerCap &&
178
+ budgetLeft &&
179
+ !timedOut &&
180
+ stoppedBecause !== "target_strong"
181
+ ) {
182
+ return { action: "retry_strategy" };
183
+ }
184
+
185
  return { action: "none" };
186
  }
lib/agent-chat/discover-jobs-tool.ts CHANGED
@@ -93,6 +93,14 @@ import { getEmbedding } from "@/lib/embeddings";
93
  import { getVectorIndex, isVectorEnabled } from "@/lib/vector-store";
94
  import crypto from "crypto";
95
  import type { ReasonedSearchAngle } from "@/lib/search-runs/types";
 
 
 
 
 
 
 
 
96
 
97
  /** Max listings sent to Groq/OpenRouter scoring per discover angle (ranked, lane-quota'd). */
98
  function discoverMaxLlmScoreListings(): number {
@@ -281,6 +289,10 @@ export interface DiscoverJobsSuccessResult {
281
  /** True when persistence loop finished without early strong target; model may show possible matches. */
282
  internalPivotsExhausted?: boolean;
283
  lastReflection?: AngleReflection;
 
 
 
 
284
  /** Durable search run id when Redis checkpoints are enabled. */
285
  searchRunId?: string;
286
  /** True when remaining work was handed to a background worker. */
@@ -2092,6 +2104,12 @@ export async function runDiscoverJobsTool(opts: {
2092
  let prevStrongTotal = 0;
2093
  let companyPivotIndex = 0;
2094
  const companyTargets = companyNamesForPivot(profile, sources);
 
 
 
 
 
 
2095
 
2096
  // Resume from durable checkpoint: seed prior scored matches and angle count.
2097
  if (resumeSeed) {
@@ -2346,6 +2364,24 @@ export async function runDiscoverJobsTool(opts: {
2346
  });
2347
  lastReflection = reflection;
2348
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2349
  const listingsThisAngle = result.strongMatches.length + result.possibleMatches.length;
2350
  const newStrongThisAngle = Math.max(0, strongN - prevStrongTotal);
2351
 
@@ -2422,10 +2458,33 @@ export async function runDiscoverJobsTool(opts: {
2422
  break;
2423
  }
2424
 
2425
- const reasonedNext = reasonedAngles[anglesRun];
2426
- if (reasonedNext) {
2427
- // angles[0] already contributed focus on the primary pass; later slots are full hypotheses.
2428
- nextIntent = enrichIntentForFocus(reasonedNext.intent.trim(), reasonedNext.focus);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2429
  nextOpts = applyReasonedFocus(
2430
  {
2431
  progressMode: "continuation",
@@ -2433,20 +2492,24 @@ export async function runDiscoverJobsTool(opts: {
2433
  pivot: "default",
2434
  angleIndex: anglesRun,
2435
  },
2436
- reasonedNext.focus,
2437
  );
2438
- const rationale = reasonedNext.rationale?.trim();
 
 
 
2439
  progress(
2440
  rationale ?
2441
- `Trying a different search hypothesis: ${rationale}`
2442
- : `Trying a distinct ${(reasonedNext.focus ?? "profile-aware").replace(/_/g, " ")} angle…`,
2443
  );
2444
  lastStrategy =
2445
- reasonedNext.focus === "skill_adjacency" ? "semantic_broadening" : null;
2446
  prevStrongTotal = strongN;
2447
  continue;
2448
  }
2449
 
 
2450
  if (anglesRun === PERSISTENCE_MAX_PIVOTS) {
2451
  nextIntent = semanticSkillsRemoteIntent(profile);
2452
  nextOpts = {
@@ -2456,6 +2519,10 @@ export async function runDiscoverJobsTool(opts: {
2456
  angleIndex: anglesRun,
2457
  };
2458
  lastStrategy = "exhausted";
 
 
 
 
2459
  continue;
2460
  }
2461
 
@@ -2496,20 +2563,28 @@ export async function runDiscoverJobsTool(opts: {
2496
  if (hirebasePivot === "broaden_role") {
2497
  nextIntent = buildBroadenRoleIntent(profile, intentTrim);
2498
  nextOpts.continuationWidening = true;
 
2499
  } else if (hirebasePivot === "extend_timeframe") {
2500
  nextIntent = intentTrim;
2501
  nextOpts.continuationWidening = true;
 
2502
  } else if (hirebasePivot === "semantic_broadening") {
2503
  nextIntent = semanticSkillsRemoteIntent(profile);
 
2504
  } else if (hirebasePivot === "company_specific") {
2505
  nextIntent = intentTrim;
2506
  const companies = companyTargets;
2507
  nextOpts.companyName = companies[companyPivotIndex % companies.length];
2508
  companyPivotIndex++;
 
2509
  } else {
2510
  nextIntent = semanticSkillsRemoteIntent(profile);
2511
  nextOpts.pivot = "semantic_broadening";
 
2512
  }
 
 
 
2513
 
2514
  prevStrongTotal = strongN;
2515
  }
@@ -2543,6 +2618,17 @@ export async function runDiscoverJobsTool(opts: {
2543
  stoppedBecause === "max_pivots" ||
2544
  stoppedBecause === "time";
2545
 
 
 
 
 
 
 
 
 
 
 
 
2546
  if (durable) {
2547
  try {
2548
  const run = await durable.get(durable.runId);
@@ -2609,6 +2695,8 @@ export async function runDiscoverJobsTool(opts: {
2609
  stoppedBecause,
2610
  internalPivotsExhausted,
2611
  lastReflection,
 
 
2612
  searchRunId: durable?.runId,
2613
  [LAB_DEBUG_PAYLOAD_KEY]: {
2614
  backend: [
 
93
  import { getVectorIndex, isVectorEnabled } from "@/lib/vector-store";
94
  import crypto from "crypto";
95
  import type { ReasonedSearchAngle } from "@/lib/search-runs/types";
96
+ import {
97
+ buildAngleObservation,
98
+ buildAvenueObservation,
99
+ type AngleObservation,
100
+ type AngleObservationSource,
101
+ type AvenueObservation,
102
+ } from "@/lib/agent-chat/discovery-observations";
103
+ import { planNextSearchAngle } from "@/lib/agent-chat/plan-next-search-angle";
104
 
105
  /** Max listings sent to Groq/OpenRouter scoring per discover angle (ranked, lane-quota'd). */
106
  function discoverMaxLlmScoreListings(): number {
 
289
  /** True when persistence loop finished without early strong target; model may show possible matches. */
290
  internalPivotsExhausted?: boolean;
291
  lastReflection?: AngleReflection;
292
+ /** Ordered inner-loop observations for this avenue (outer reflection input). */
293
+ strategyTrace?: AngleObservation[];
294
+ /** Rolled-up avenue summary for outer strategy. */
295
+ avenueObservation?: AvenueObservation;
296
  /** Durable search run id when Redis checkpoints are enabled. */
297
  searchRunId?: string;
298
  /** True when remaining work was handed to a background worker. */
 
2104
  let prevStrongTotal = 0;
2105
  let companyPivotIndex = 0;
2106
  const companyTargets = companyNamesForPivot(profile, sources);
2107
+ const strategyTrace: AngleObservation[] = [];
2108
+ const priorAngleSummaries: Array<{ intent: string; focus?: ReasonedSearchAngle["focus"] }> = [];
2109
+ let currentAngleSource: AngleObservationSource = "seed";
2110
+ let currentAngleRationale: string | undefined = reasonedAngles[0]?.rationale;
2111
+ let currentAngleFocus: ReasonedSearchAngle["focus"] | undefined = reasonedAngles[0]?.focus;
2112
+ const avenueApproach = intentTrim;
2113
 
2114
  // Resume from durable checkpoint: seed prior scored matches and angle count.
2115
  if (resumeSeed) {
 
2364
  });
2365
  lastReflection = reflection;
2366
 
2367
+ const angleObservation = buildAngleObservation({
2368
+ angleIndex: anglesRun - 1,
2369
+ intent: nextIntent,
2370
+ focus: currentAngleFocus,
2371
+ rationale: currentAngleRationale,
2372
+ reflection,
2373
+ gatedScored: meta.gatedScored,
2374
+ scoredBeforeGate: meta.scoredBeforeGate,
2375
+ newUniqueDiscovered: meta.newUniqueDiscovered,
2376
+ totalDiscoveredThisAngle: result.totalDiscovered,
2377
+ source: currentAngleSource,
2378
+ });
2379
+ strategyTrace.push(angleObservation);
2380
+ priorAngleSummaries.push({
2381
+ intent: nextIntent,
2382
+ ...(currentAngleFocus ? { focus: currentAngleFocus } : {}),
2383
+ });
2384
+
2385
  const listingsThisAngle = result.strongMatches.length + result.possibleMatches.length;
2386
  const newStrongThisAngle = Math.max(0, strongN - prevStrongTotal);
2387
 
 
2458
  break;
2459
  }
2460
 
2461
+ // Inner adapt: LLM reflects on observations; candidates are hints only (not a playlist).
2462
+ const unusedCandidates = reasonedAngles.slice(anglesRun);
2463
+ const planned = await planNextSearchAngle({
2464
+ avenueApproach,
2465
+ profile,
2466
+ latest: angleObservation,
2467
+ priorAngles: priorAngleSummaries,
2468
+ candidates: unusedCandidates,
2469
+ strongMatchCount: strongN,
2470
+ targetStrong: PERSISTENCE_TARGET_STRONG,
2471
+ });
2472
+
2473
+ if (planned.action === "stop") {
2474
+ progress(
2475
+ planned.rationale?.trim() ?
2476
+ planned.rationale.trim()
2477
+ : strongN > 0 ?
2478
+ "This search avenue looks solid enough. Wrapping up…"
2479
+ : "This search avenue looks exhausted. Wrapping up…",
2480
+ );
2481
+ stoppedBecause = "completed";
2482
+ prevStrongTotal = strongN;
2483
+ break;
2484
+ }
2485
+
2486
+ if (planned.action === "adapt") {
2487
+ nextIntent = enrichIntentForFocus(planned.intent.trim(), planned.focus);
2488
  nextOpts = applyReasonedFocus(
2489
  {
2490
  progressMode: "continuation",
 
2492
  pivot: "default",
2493
  angleIndex: anglesRun,
2494
  },
2495
+ planned.focus,
2496
  );
2497
+ currentAngleSource = "llm";
2498
+ currentAngleFocus = planned.focus;
2499
+ currentAngleRationale = planned.rationale;
2500
+ const rationale = planned.rationale?.trim();
2501
  progress(
2502
  rationale ?
2503
+ `Adapting this avenue: ${rationale}`
2504
+ : `Adapting this avenue with a refined ${(planned.focus ?? "profile-aware").replace(/_/g, " ")} angle…`,
2505
  );
2506
  lastStrategy =
2507
+ planned.focus === "skill_adjacency" ? "semantic_broadening" : null;
2508
  prevStrongTotal = strongN;
2509
  continue;
2510
  }
2511
 
2512
+ // Deterministic floor when the inner planner fails open.
2513
  if (anglesRun === PERSISTENCE_MAX_PIVOTS) {
2514
  nextIntent = semanticSkillsRemoteIntent(profile);
2515
  nextOpts = {
 
2519
  angleIndex: anglesRun,
2520
  };
2521
  lastStrategy = "exhausted";
2522
+ currentAngleSource = "deterministic";
2523
+ currentAngleFocus = "skill_adjacency";
2524
+ currentAngleRationale = "Deterministic semantic broadening after planner fallback.";
2525
+ prevStrongTotal = strongN;
2526
  continue;
2527
  }
2528
 
 
2563
  if (hirebasePivot === "broaden_role") {
2564
  nextIntent = buildBroadenRoleIntent(profile, intentTrim);
2565
  nextOpts.continuationWidening = true;
2566
+ currentAngleFocus = undefined;
2567
  } else if (hirebasePivot === "extend_timeframe") {
2568
  nextIntent = intentTrim;
2569
  nextOpts.continuationWidening = true;
2570
+ currentAngleFocus = undefined;
2571
  } else if (hirebasePivot === "semantic_broadening") {
2572
  nextIntent = semanticSkillsRemoteIntent(profile);
2573
+ currentAngleFocus = "skill_adjacency";
2574
  } else if (hirebasePivot === "company_specific") {
2575
  nextIntent = intentTrim;
2576
  const companies = companyTargets;
2577
  nextOpts.companyName = companies[companyPivotIndex % companies.length];
2578
  companyPivotIndex++;
2579
+ currentAngleFocus = undefined;
2580
  } else {
2581
  nextIntent = semanticSkillsRemoteIntent(profile);
2582
  nextOpts.pivot = "semantic_broadening";
2583
+ currentAngleFocus = "skill_adjacency";
2584
  }
2585
+ currentAngleSource = "deterministic";
2586
+ currentAngleRationale = `Deterministic pivot: ${pivot.replace(/_/g, " ")}.`;
2587
+ progress(`Trying a different angle (${pivot.replace(/_/g, " ")})…`);
2588
 
2589
  prevStrongTotal = strongN;
2590
  }
 
2618
  stoppedBecause === "max_pivots" ||
2619
  stoppedBecause === "time";
2620
 
2621
+ const avenueObservation = buildAvenueObservation({
2622
+ approach: avenueApproach,
2623
+ anglesRun,
2624
+ stoppedBecause,
2625
+ strongMatches: allStrong,
2626
+ possibleMatches: allPossible,
2627
+ totalDiscovered: urlsDiscoveredThisSession.size,
2628
+ strategyTrace,
2629
+ targetStrong: PERSISTENCE_TARGET_STRONG,
2630
+ });
2631
+
2632
  if (durable) {
2633
  try {
2634
  const run = await durable.get(durable.runId);
 
2695
  stoppedBecause,
2696
  internalPivotsExhausted,
2697
  lastReflection,
2698
+ strategyTrace,
2699
+ avenueObservation,
2700
  searchRunId: durable?.runId,
2701
  [LAB_DEBUG_PAYLOAD_KEY]: {
2702
  backend: [
lib/agent-chat/discovery-observations.selftest.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+
3
+ import {
4
+ avenueObservationForLlm,
5
+ buildAngleObservation,
6
+ buildAvenueObservation,
7
+ extractPromisingTerminology,
8
+ worthContinuingHintFromCounts,
9
+ } from "@/lib/agent-chat/discovery-observations";
10
+ import type { ScoreResult } from "@/lib/types";
11
+
12
+ function dim(score: number): ScoreResult["role_fit"] {
13
+ return { score, notes: "" };
14
+ }
15
+
16
+ function scored(
17
+ title: string,
18
+ verdict: "Strong Match" | "Possible" | "Skip",
19
+ remote: number,
20
+ role: number,
21
+ flags: string[] = [],
22
+ ) {
23
+ return {
24
+ title,
25
+ company: "Acme Labs",
26
+ scoresSnapshot: {
27
+ role_fit: dim(role),
28
+ global_remote: dim(remote),
29
+ culture_async: dim(5),
30
+ contractor_friendly: dim(5),
31
+ skill_gaps: [],
32
+ red_flags: flags,
33
+ green_flags: [],
34
+ verdict: "Skip" as const,
35
+ verdict_reason: "t",
36
+ },
37
+ verdict,
38
+ };
39
+ }
40
+
41
+ function testPromisingTerminology(): void {
42
+ const terms = extractPromisingTerminology([
43
+ scored("Senior React Engineer", "Strong Match", 8, 8),
44
+ scored("React TypeScript Frontend", "Possible", 7, 7),
45
+ scored("Junior Java", "Skip", 3, 3),
46
+ ]);
47
+ assert.ok(terms.includes("react"));
48
+ assert.ok(terms.includes("typescript") || terms.includes("frontend") || terms.includes("acme"));
49
+ assert.ok(!terms.includes("junior"));
50
+ }
51
+
52
+ function testAngleObservationShape(): void {
53
+ const rows = [
54
+ scored("Senior React", "Strong Match", 8, 8),
55
+ scored("US Only Role", "Skip", 2, 6, ["US-only role"]),
56
+ ];
57
+ const obs = buildAngleObservation({
58
+ angleIndex: 0,
59
+ intent: "senior React remote",
60
+ focus: "work_from_anywhere",
61
+ rationale: "WFA first",
62
+ reflection: {
63
+ dominantFailureReason: "low_remote_score",
64
+ suggestedPivot: "semantic_broadening",
65
+ },
66
+ gatedScored: rows,
67
+ newUniqueDiscovered: 2,
68
+ totalDiscoveredThisAngle: 5,
69
+ source: "seed",
70
+ });
71
+ assert.equal(obs.resultQuality.strong, 1);
72
+ assert.equal(obs.geographySignals.dominantRemoteFailure, true);
73
+ assert.ok((obs.geographySignals.usOnlyFlagCount ?? 0) >= 1);
74
+ assert.equal(obs.novelty.newUniqueDiscovered, 2);
75
+ assert.ok(obs.novelty.duplicateOrSeenRate !== undefined);
76
+ assert.ok(obs.promisingTerminology.length >= 1);
77
+ assert.equal(obs.source, "seed");
78
+ }
79
+
80
+ function testAvenueObservationAndHint(): void {
81
+ assert.equal(
82
+ worthContinuingHintFromCounts({ strong: 5, possible: 0, totalDiscovered: 20 }),
83
+ "solid",
84
+ );
85
+ assert.equal(
86
+ worthContinuingHintFromCounts({ strong: 1, possible: 0, totalDiscovered: 10 }),
87
+ "thin_but_promising",
88
+ );
89
+ assert.equal(
90
+ worthContinuingHintFromCounts({ strong: 0, possible: 0, totalDiscovered: 0 }),
91
+ "empty",
92
+ );
93
+ assert.equal(
94
+ worthContinuingHintFromCounts({ strong: 0, possible: 1, totalDiscovered: 30 }),
95
+ "weak",
96
+ );
97
+
98
+ const avenue = buildAvenueObservation({
99
+ approach: "senior React remote",
100
+ anglesRun: 2,
101
+ stoppedBecause: "diminishing",
102
+ strongMatches: [scored("Senior React", "Strong Match", 8, 8)],
103
+ possibleMatches: [scored("Frontend TS", "Possible", 7, 7)],
104
+ totalDiscovered: 12,
105
+ strategyTrace: [
106
+ buildAngleObservation({
107
+ angleIndex: 0,
108
+ intent: "senior React remote",
109
+ reflection: {
110
+ dominantFailureReason: "low_remote_score",
111
+ suggestedPivot: "semantic_broadening",
112
+ },
113
+ gatedScored: [],
114
+ newUniqueDiscovered: 3,
115
+ totalDiscoveredThisAngle: 5,
116
+ source: "seed",
117
+ }),
118
+ ],
119
+ });
120
+ assert.equal(avenue.worthContinuingHint, "thin_but_promising");
121
+ assert.ok(avenue.dominantFailures.includes("low_remote_score"));
122
+ const llm = avenueObservationForLlm(avenue);
123
+ assert.equal(llm.approach, "senior React remote");
124
+ assert.ok(Array.isArray(llm.strategyTrace));
125
+ }
126
+
127
+ function main(): void {
128
+ testPromisingTerminology();
129
+ testAngleObservationShape();
130
+ testAvenueObservationAndHint();
131
+ console.log("discovery-observations.selftest: ok");
132
+ }
133
+
134
+ main();
lib/agent-chat/discovery-observations.ts ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Structured observations crossing the inner→outer discovery boundary.
3
+ * Outer strategy reasons over these; it does not receive raw listing dumps.
4
+ */
5
+
6
+ import type { ReasonedSearchAngle } from "@/lib/search-runs/types";
7
+ import type { ScoreResult } from "@/lib/types";
8
+
9
+ export type AngleObservationSource = "llm" | "deterministic" | "seed";
10
+
11
+ export type DominantFailureReason =
12
+ | "low_role_fit"
13
+ | "low_remote_score"
14
+ | "blocked_fetches"
15
+ | "no_results"
16
+ | "high_volume_low_yield"
17
+ | null;
18
+
19
+ export type SuggestedPivot =
20
+ | "broaden_role"
21
+ | "extend_timeframe"
22
+ | "semantic_broadening"
23
+ | "company_specific"
24
+ | "exhausted";
25
+
26
+ /** Minimal scored row shape — avoids importing discover-jobs-tool (circular). */
27
+ export type ObservationScoredListing = {
28
+ title: string;
29
+ company?: string;
30
+ scoresSnapshot: ScoreResult;
31
+ verdict: "Strong Match" | "Possible" | "Skip";
32
+ };
33
+
34
+ export type AngleResultQuality = {
35
+ strong: number;
36
+ possible: number;
37
+ skip: number;
38
+ meanRoleFit?: number;
39
+ meanGlobalRemote?: number;
40
+ };
41
+
42
+ export type AngleNovelty = {
43
+ newUniqueDiscovered: number;
44
+ /** 0–1 share of discovered URLs already seen this session (when totals known). */
45
+ duplicateOrSeenRate?: number;
46
+ };
47
+
48
+ export type AngleObservation = {
49
+ angleIndex: number;
50
+ intent: string;
51
+ focus?: ReasonedSearchAngle["focus"];
52
+ rationale?: string;
53
+ resultQuality: AngleResultQuality;
54
+ /** Share of scored rows with role_fit below 5 (0–1). */
55
+ roleFitBelowShare?: number;
56
+ geographySignals: {
57
+ dominantRemoteFailure: boolean;
58
+ lowRemoteShare?: number;
59
+ usOnlyFlagCount?: number;
60
+ };
61
+ novelty: AngleNovelty;
62
+ failurePatterns: {
63
+ dominantFailureReason: DominantFailureReason;
64
+ suggestedPivot: SuggestedPivot;
65
+ };
66
+ promisingTerminology: string[];
67
+ source: AngleObservationSource;
68
+ };
69
+
70
+ export type AvenueTopListing = {
71
+ title: string;
72
+ company?: string;
73
+ verdict: "Strong Match" | "Possible";
74
+ };
75
+
76
+ export type AvenueObservation = {
77
+ approach: string;
78
+ anglesRun: number;
79
+ stoppedBecause?: string;
80
+ strongMatchCount: number;
81
+ possibleMatchCount: number;
82
+ totalDiscovered: number;
83
+ dominantFailures: Array<NonNullable<DominantFailureReason>>;
84
+ strategyTrace: AngleObservation[];
85
+ topListings: AvenueTopListing[];
86
+ /** Coarse hint for outer reflection: some signal but thin vs empty vs solid. */
87
+ worthContinuingHint: "solid" | "thin_but_promising" | "weak" | "empty";
88
+ };
89
+
90
+ const STOP_WORDS = new Set([
91
+ "the",
92
+ "and",
93
+ "for",
94
+ "with",
95
+ "senior",
96
+ "junior",
97
+ "remote",
98
+ "role",
99
+ "roles",
100
+ "engineer",
101
+ "engineering",
102
+ "software",
103
+ "developer",
104
+ "job",
105
+ "jobs",
106
+ ]);
107
+
108
+ function meanScore(
109
+ rows: ObservationScoredListing[],
110
+ key: "role_fit" | "global_remote",
111
+ ): number | undefined {
112
+ if (rows.length === 0) return undefined;
113
+ let sum = 0;
114
+ let n = 0;
115
+ for (const r of rows) {
116
+ const v = Number(r.scoresSnapshot[key]?.score);
117
+ if (Number.isFinite(v)) {
118
+ sum += v;
119
+ n++;
120
+ }
121
+ }
122
+ return n > 0 ? Math.round((sum / n) * 10) / 10 : undefined;
123
+ }
124
+
125
+ /** Recurring title/company tokens from Strong/Possible — heuristic, not LLM. */
126
+ export function extractPromisingTerminology(
127
+ rows: ObservationScoredListing[],
128
+ cap = 8,
129
+ ): string[] {
130
+ const counts = new Map<string, number>();
131
+ for (const r of rows) {
132
+ if (r.verdict !== "Strong Match" && r.verdict !== "Possible") continue;
133
+ const blob = `${r.title ?? ""} ${r.company ?? ""}`;
134
+ for (const raw of blob.split(/[\s,/|·•\-–—]+/)) {
135
+ const t = raw.toLowerCase().replace(/[^a-z0-9+#.]/g, "");
136
+ if (t.length < 3 || STOP_WORDS.has(t)) continue;
137
+ counts.set(t, (counts.get(t) ?? 0) + 1);
138
+ }
139
+ }
140
+ return [...counts.entries()]
141
+ .filter(([, c]) => c >= 1)
142
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
143
+ .slice(0, cap)
144
+ .map(([t]) => t);
145
+ }
146
+
147
+ function countUsOnlyFlags(rows: ObservationScoredListing[]): number {
148
+ let n = 0;
149
+ for (const r of rows) {
150
+ const flags = [
151
+ ...(r.scoresSnapshot.red_flags ?? []),
152
+ ...(r.scoresSnapshot.green_flags ?? []),
153
+ ];
154
+ if (flags.some((f) => /\b(us[- ]?only|united states only|usa only|must be in (the )?us)\b/i.test(f))) {
155
+ n++;
156
+ }
157
+ }
158
+ return n;
159
+ }
160
+
161
+ export function buildAngleObservation(opts: {
162
+ angleIndex: number;
163
+ intent: string;
164
+ focus?: ReasonedSearchAngle["focus"];
165
+ rationale?: string;
166
+ reflection: {
167
+ dominantFailureReason: DominantFailureReason;
168
+ suggestedPivot: SuggestedPivot;
169
+ };
170
+ gatedScored: ObservationScoredListing[];
171
+ scoredBeforeGate?: ObservationScoredListing[];
172
+ newUniqueDiscovered: number;
173
+ totalDiscoveredThisAngle: number;
174
+ urlsAlreadySeenBeforeAngle?: number;
175
+ source: AngleObservationSource;
176
+ }): AngleObservation {
177
+ const {
178
+ angleIndex,
179
+ intent,
180
+ focus,
181
+ rationale,
182
+ reflection,
183
+ gatedScored,
184
+ scoredBeforeGate,
185
+ newUniqueDiscovered,
186
+ totalDiscoveredThisAngle,
187
+ source,
188
+ } = opts;
189
+ const pool = gatedScored.length > 0 ? gatedScored : (scoredBeforeGate ?? []);
190
+ const strong = gatedScored.filter((r) => r.verdict === "Strong Match").length;
191
+ const possible = gatedScored.filter((r) => r.verdict === "Possible").length;
192
+ const skip = gatedScored.filter((r) => r.verdict === "Skip").length;
193
+ const lowRole = pool.filter((r) => Number(r.scoresSnapshot.role_fit?.score ?? 0) < 5).length;
194
+ const lowRemote = pool.filter((r) => Number(r.scoresSnapshot.global_remote?.score ?? 0) < 5).length;
195
+ const duplicateOrSeenRate =
196
+ totalDiscoveredThisAngle > 0 ?
197
+ Math.min(
198
+ 1,
199
+ Math.max(0, (totalDiscoveredThisAngle - newUniqueDiscovered) / totalDiscoveredThisAngle),
200
+ )
201
+ : undefined;
202
+
203
+ return {
204
+ angleIndex,
205
+ intent: intent.trim().slice(0, 200),
206
+ ...(focus ? { focus } : {}),
207
+ ...(rationale?.trim() ? { rationale: rationale.trim().slice(0, 240) } : {}),
208
+ resultQuality: {
209
+ strong,
210
+ possible,
211
+ skip,
212
+ ...(meanScore(pool, "role_fit") !== undefined ?
213
+ { meanRoleFit: meanScore(pool, "role_fit") }
214
+ : {}),
215
+ ...(meanScore(pool, "global_remote") !== undefined ?
216
+ { meanGlobalRemote: meanScore(pool, "global_remote") }
217
+ : {}),
218
+ },
219
+ ...(pool.length > 0 ? { roleFitBelowShare: Math.round((lowRole / pool.length) * 100) / 100 } : {}),
220
+ geographySignals: {
221
+ dominantRemoteFailure: reflection.dominantFailureReason === "low_remote_score",
222
+ ...(pool.length > 0 ?
223
+ { lowRemoteShare: Math.round((lowRemote / pool.length) * 100) / 100 }
224
+ : {}),
225
+ usOnlyFlagCount: countUsOnlyFlags(pool),
226
+ },
227
+ novelty: {
228
+ newUniqueDiscovered,
229
+ ...(duplicateOrSeenRate !== undefined ? { duplicateOrSeenRate } : {}),
230
+ },
231
+ failurePatterns: {
232
+ dominantFailureReason: reflection.dominantFailureReason,
233
+ suggestedPivot: reflection.suggestedPivot,
234
+ },
235
+ promisingTerminology: extractPromisingTerminology(
236
+ gatedScored.filter((r) => r.verdict === "Strong Match" || r.verdict === "Possible"),
237
+ ),
238
+ source,
239
+ };
240
+ }
241
+
242
+ export function worthContinuingHintFromCounts(opts: {
243
+ strong: number;
244
+ possible: number;
245
+ totalDiscovered: number;
246
+ targetStrong?: number;
247
+ }): AvenueObservation["worthContinuingHint"] {
248
+ const target = opts.targetStrong ?? 5;
249
+ if (opts.strong >= target) return "solid";
250
+ if (opts.strong > 0 || opts.possible >= 3) return "thin_but_promising";
251
+ if (opts.totalDiscovered === 0 && opts.strong === 0 && opts.possible === 0) return "empty";
252
+ return "weak";
253
+ }
254
+
255
+ export function buildAvenueObservation(opts: {
256
+ approach: string;
257
+ anglesRun: number;
258
+ stoppedBecause?: string;
259
+ strongMatches: ObservationScoredListing[];
260
+ possibleMatches: ObservationScoredListing[];
261
+ totalDiscovered: number;
262
+ strategyTrace: AngleObservation[];
263
+ targetStrong?: number;
264
+ }): AvenueObservation {
265
+ const strong = opts.strongMatches.length;
266
+ const possible = opts.possibleMatches.length;
267
+ const dominantFailures = opts.strategyTrace
268
+ .map((a) => a.failurePatterns.dominantFailureReason)
269
+ .filter((x): x is NonNullable<DominantFailureReason> => Boolean(x));
270
+ const uniqFailures = [...new Set(dominantFailures)];
271
+ const topListings: AvenueTopListing[] = [
272
+ ...opts.strongMatches.slice(0, 4).map((r) => ({
273
+ title: r.title,
274
+ ...(r.company ? { company: r.company } : {}),
275
+ verdict: "Strong Match" as const,
276
+ })),
277
+ ...opts.possibleMatches.slice(0, 3).map((r) => ({
278
+ title: r.title,
279
+ ...(r.company ? { company: r.company } : {}),
280
+ verdict: "Possible" as const,
281
+ })),
282
+ ].slice(0, 6);
283
+
284
+ return {
285
+ approach: opts.approach.trim().slice(0, 240),
286
+ anglesRun: opts.anglesRun,
287
+ ...(opts.stoppedBecause ? { stoppedBecause: opts.stoppedBecause } : {}),
288
+ strongMatchCount: strong,
289
+ possibleMatchCount: possible,
290
+ totalDiscovered: opts.totalDiscovered,
291
+ dominantFailures: uniqFailures,
292
+ strategyTrace: opts.strategyTrace,
293
+ topListings,
294
+ worthContinuingHint: worthContinuingHintFromCounts({
295
+ strong,
296
+ possible,
297
+ totalDiscovered: opts.totalDiscovered,
298
+ targetStrong: opts.targetStrong,
299
+ }),
300
+ };
301
+ }
302
+
303
+ /** Compact shape for the chat LLM (outer reflection). */
304
+ export function avenueObservationForLlm(obs: AvenueObservation): Record<string, unknown> {
305
+ return {
306
+ approach: obs.approach,
307
+ anglesRun: obs.anglesRun,
308
+ stoppedBecause: obs.stoppedBecause,
309
+ strongMatchCount: obs.strongMatchCount,
310
+ possibleMatchCount: obs.possibleMatchCount,
311
+ totalDiscovered: obs.totalDiscovered,
312
+ dominantFailures: obs.dominantFailures,
313
+ worthContinuingHint: obs.worthContinuingHint,
314
+ topListings: obs.topListings,
315
+ strategyTrace: obs.strategyTrace.map((a) => ({
316
+ angleIndex: a.angleIndex,
317
+ intent: a.intent,
318
+ focus: a.focus,
319
+ rationale: a.rationale,
320
+ resultQuality: a.resultQuality,
321
+ roleFitBelowShare: a.roleFitBelowShare,
322
+ geographySignals: a.geographySignals,
323
+ novelty: a.novelty,
324
+ failurePatterns: a.failurePatterns,
325
+ promisingTerminology: a.promisingTerminology,
326
+ source: a.source,
327
+ })),
328
+ };
329
+ }
lib/agent-chat/plan-next-search-angle.selftest.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+
3
+ import { parsePlanNextSearchAngleJson } from "@/lib/agent-chat/plan-next-search-angle";
4
+
5
+ function testParseAdapt(): void {
6
+ const r = parsePlanNextSearchAngleJson({
7
+ action: "adapt",
8
+ intent: "senior frontend React remote EMEA",
9
+ focus: "work_from_anywhere",
10
+ rationale: "Prior hits were US-only; bias EMEA",
11
+ });
12
+ assert.equal(r?.action, "adapt");
13
+ if (r?.action === "adapt") {
14
+ assert.equal(r.intent, "senior frontend React remote EMEA");
15
+ assert.equal(r.focus, "work_from_anywhere");
16
+ assert.equal(r.source, "llm");
17
+ }
18
+ }
19
+
20
+ function testParseStop(): void {
21
+ const r = parsePlanNextSearchAngleJson({
22
+ action: "stop",
23
+ rationale: "Quality is good enough for this avenue",
24
+ });
25
+ assert.equal(r?.action, "stop");
26
+ }
27
+
28
+ function testParseRejectsPlaylistBlindnessInputs(): void {
29
+ assert.equal(parsePlanNextSearchAngleJson({ action: "adapt", intent: "" }), null);
30
+ assert.equal(parsePlanNextSearchAngleJson({ action: "adapt", intent: "ab" }), null);
31
+ assert.equal(parsePlanNextSearchAngleJson(null), null);
32
+ const badFocus = parsePlanNextSearchAngleJson({
33
+ action: "adapt",
34
+ intent: "senior React remote",
35
+ focus: "site:greenhouse.io",
36
+ });
37
+ assert.equal(badFocus?.action, "adapt");
38
+ if (badFocus?.action === "adapt") {
39
+ assert.equal(badFocus.focus, undefined);
40
+ }
41
+ }
42
+
43
+ function main(): void {
44
+ testParseAdapt();
45
+ testParseStop();
46
+ testParseRejectsPlaylistBlindnessInputs();
47
+ console.log("plan-next-search-angle.selftest: ok");
48
+ }
49
+
50
+ main();
lib/agent-chat/plan-next-search-angle.ts ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Inner-loop adapter: given angle observations, propose the next search angle
3
+ * (or stop). LLM-first with deterministic fail-open. Never authors provider query strings.
4
+ */
5
+
6
+ import { chatCompletionWithTools } from "@/lib/agent-chat/groq-tools-client";
7
+ import type { AngleObservation } from "@/lib/agent-chat/discovery-observations";
8
+ import type { ReasonedSearchAngle } from "@/lib/search-runs/types";
9
+ import type { UserProfile } from "@/lib/user-profile";
10
+
11
+ const FOCUS_SET = new Set<ReasonedSearchAngle["focus"]>([
12
+ "ats_first",
13
+ "work_from_anywhere",
14
+ "visa_sponsorship",
15
+ "skill_adjacency",
16
+ ]);
17
+
18
+ const INNER_PLANNER_TIMEOUT_MS = (() => {
19
+ const n = parseInt(String(process.env.DISCOVER_INNER_PLANNER_TIMEOUT_MS ?? "8000").trim(), 10);
20
+ return Number.isFinite(n) ? Math.min(20_000, Math.max(2_000, n)) : 8_000;
21
+ })();
22
+
23
+ export type PlanNextSearchAngleResult =
24
+ | { action: "stop"; rationale?: string }
25
+ | {
26
+ action: "adapt";
27
+ intent: string;
28
+ focus?: ReasonedSearchAngle["focus"];
29
+ rationale?: string;
30
+ source: "llm";
31
+ }
32
+ | { action: "fallback_deterministic" };
33
+
34
+ function profileGist(profile: UserProfile): string {
35
+ const pref = profile.jobPreferences;
36
+ const titles =
37
+ Array.isArray(pref?.searchTitleVariants) ?
38
+ pref!.searchTitleVariants.filter((x): x is string => typeof x === "string").slice(0, 4)
39
+ : [];
40
+ const stack =
41
+ Array.isArray(pref?.preferredTechStack) ?
42
+ pref!.preferredTechStack.filter((x): x is string => typeof x === "string").slice(0, 6)
43
+ : [];
44
+ const seniority =
45
+ Array.isArray(pref?.seniorityLevels) ?
46
+ pref!.seniorityLevels.filter((x): x is string => typeof x === "string").slice(0, 3)
47
+ : [];
48
+ return [
49
+ profile.headline?.trim(),
50
+ titles.length ? `titles:${titles.join(", ")}` : "",
51
+ seniority.length ? `seniority:${seniority.join(", ")}` : "",
52
+ stack.length ? `stack:${stack.join(", ")}` : "",
53
+ pref?.workPreference ? `work:${pref.workPreference}` : "",
54
+ ]
55
+ .filter(Boolean)
56
+ .join(" · ")
57
+ .slice(0, 400);
58
+ }
59
+
60
+ function extractJsonObject(text: string): unknown {
61
+ const trimmed = text.trim();
62
+ const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
63
+ const raw = fence?.[1]?.trim() ?? trimmed;
64
+ const start = raw.indexOf("{");
65
+ const end = raw.lastIndexOf("}");
66
+ if (start < 0 || end <= start) throw new Error("no_json_object");
67
+ return JSON.parse(raw.slice(start, end + 1));
68
+ }
69
+
70
+ /** Parse / validate LLM JSON for the next inner angle. Exported for tests. */
71
+ export function parsePlanNextSearchAngleJson(raw: unknown): PlanNextSearchAngleResult | null {
72
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
73
+ const o = raw as Record<string, unknown>;
74
+ const action = typeof o.action === "string" ? o.action.trim().toLowerCase() : "";
75
+ if (action === "stop") {
76
+ return {
77
+ action: "stop",
78
+ ...(typeof o.rationale === "string" && o.rationale.trim() ?
79
+ { rationale: o.rationale.trim().slice(0, 240) }
80
+ : {}),
81
+ };
82
+ }
83
+ if (action !== "adapt" && action !== "continue" && action !== "next") return null;
84
+ const intent = typeof o.intent === "string" ? o.intent.trim() : "";
85
+ if (!intent || intent.length < 3) return null;
86
+ const focusRaw = typeof o.focus === "string" ? o.focus.trim() : "";
87
+ const focus =
88
+ FOCUS_SET.has(focusRaw as ReasonedSearchAngle["focus"]) ?
89
+ (focusRaw as ReasonedSearchAngle["focus"])
90
+ : undefined;
91
+ const rationale =
92
+ typeof o.rationale === "string" && o.rationale.trim() ?
93
+ o.rationale.trim().slice(0, 240)
94
+ : undefined;
95
+ return {
96
+ action: "adapt",
97
+ intent: intent.slice(0, 200),
98
+ ...(focus ? { focus } : {}),
99
+ ...(rationale ? { rationale } : {}),
100
+ source: "llm",
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Ask the model how to adapt within the current avenue.
106
+ * Returns fallback_deterministic on timeout, API failure, or invalid JSON.
107
+ */
108
+ export async function planNextSearchAngle(opts: {
109
+ avenueApproach: string;
110
+ profile: UserProfile;
111
+ latest: AngleObservation;
112
+ priorAngles: Array<{ intent: string; focus?: ReasonedSearchAngle["focus"] }>;
113
+ candidates?: ReasonedSearchAngle[];
114
+ strongMatchCount: number;
115
+ targetStrong: number;
116
+ }): Promise<PlanNextSearchAngleResult> {
117
+ const system = [
118
+ "You adapt job-search angles inside ONE research avenue (local improvement, not a new strategy).",
119
+ "Providers compile your intent into safe queries — never invent site: operators or raw API params.",
120
+ "Return ONLY JSON with one of:",
121
+ '{"action":"adapt","intent":"...","focus":"ats_first|work_from_anywhere|visa_sponsorship|skill_adjacency","rationale":"..."}',
122
+ '{"action":"stop","rationale":"..."}',
123
+ "Stop when quality is good enough for this avenue, or further tweaks would not help.",
124
+ "Adapt when geography, seniority, stack, or volume failed — change the hypothesis meaningfully.",
125
+ "Optional candidates are hints only; rewrite or discard them.",
126
+ ].join(" ");
127
+
128
+ const user = JSON.stringify(
129
+ {
130
+ avenueApproach: opts.avenueApproach,
131
+ profileGist: profileGist(opts.profile),
132
+ strongMatchCount: opts.strongMatchCount,
133
+ targetStrong: opts.targetStrong,
134
+ latestObservation: opts.latest,
135
+ priorAnglesThisAvenue: opts.priorAngles,
136
+ candidateHypotheses: (opts.candidates ?? []).slice(0, 4),
137
+ },
138
+ null,
139
+ 0,
140
+ );
141
+
142
+ const run = async (): Promise<PlanNextSearchAngleResult> => {
143
+ const msg = await chatCompletionWithTools(
144
+ [
145
+ { role: "system", content: system },
146
+ { role: "user", content: user },
147
+ ],
148
+ [],
149
+ { toolChoice: "none" },
150
+ );
151
+ const content = typeof msg.content === "string" ? msg.content : "";
152
+ if (!content.trim()) return { action: "fallback_deterministic" };
153
+ try {
154
+ const parsed = parsePlanNextSearchAngleJson(extractJsonObject(content));
155
+ return parsed ?? { action: "fallback_deterministic" };
156
+ } catch {
157
+ return { action: "fallback_deterministic" };
158
+ }
159
+ };
160
+
161
+ try {
162
+ const timed = await Promise.race([
163
+ run(),
164
+ new Promise<PlanNextSearchAngleResult>((resolve) =>
165
+ setTimeout(() => resolve({ action: "fallback_deterministic" }), INNER_PLANNER_TIMEOUT_MS),
166
+ ),
167
+ ]);
168
+ return timed;
169
+ } catch {
170
+ return { action: "fallback_deterministic" };
171
+ }
172
+ }
lib/agent-chat/run-conversation.ts CHANGED
@@ -91,8 +91,10 @@ import {
91
  evaluateDiscoverOutcomePolicy,
92
  humanizeToolError,
93
  isColloquialResumeMessage,
 
94
  shouldTreatAsHardSearchFailure,
95
  } from "@/lib/agent-chat/autonomy-policy";
 
96
 
97
  const MAX_TOOL_CALLS = 10;
98
  const MAX_HISTORY = 24;
@@ -176,9 +178,13 @@ const GROUNDING_SYSTEM_RULES = [
176
  "## Tool grounding (critical)",
177
  "You are a tool-calling agent. Job listings in your responses must come exclusively from tool results.",
178
  "Never invent job titles, companies, or URLs. If a tool returns nothing, say so.",
179
- "discover_jobs already ran internal pivots — one call per intent per turn unless outcome policy asks for recency=\"48h\".",
 
 
 
 
180
  "For ordinary \"find roles for me\" / profile searches, call discover_jobs first. Do not start with run_batch_search unless the user asked for a deeper background/batch search or discover already returned empty/timeout.",
181
- "When inference is available, include 2–4 distinct searchAngles in discover_jobs: ATS-first plus profile-relevant work-from-anywhere, visa, or adjacent-skill hypotheses. Do not write raw site: queries.",
182
  "After a timeout, show partials and offer a background search; do not retry discovery in the same turn.",
183
  "If a search tool errors, say search is temporarily unavailable and stop. Do not retry discover_jobs or call run_batch_search in that turn.",
184
  "If discover_jobs succeeds with zero listings (no error), say nothing was found in the last 48 hours honestly and you may offer batch search — do not say search is unavailable.",
@@ -429,6 +435,7 @@ function discoverJobsPayloadForLlm(
429
  payload: unknown,
430
  jobs?: AgentJobCard[],
431
  prefs?: UserProfile["jobPreferences"],
 
432
  ): Record<string, unknown> | undefined {
433
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined;
434
  const p = payload as Record<string, unknown>;
@@ -441,10 +448,38 @@ function discoverJobsPayloadForLlm(
441
  {
442
  dominantFailureReason: (lr as Record<string, unknown>).dominantFailureReason,
443
  suggestedPivot: (lr as Record<string, unknown>).suggestedPivot,
 
 
 
444
  }
445
  : undefined;
446
  const sectionCounts =
447
  jobs?.length ? countJobsByOpportunitySection(jobs, prefs) : undefined;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  return {
449
  totalDiscovered: p.totalDiscovered,
450
  totalScored: p.totalScored,
@@ -464,6 +499,11 @@ function discoverJobsPayloadForLlm(
464
  stoppedBecause: p.stoppedBecause,
465
  internalPivotsExhausted: p.internalPivotsExhausted,
466
  lastReflection: reflectionSummary,
 
 
 
 
 
467
  };
468
  }
469
 
@@ -476,6 +516,8 @@ function buildLlmToolContent(opts: {
476
  jobPreferences?: UserProfile["jobPreferences"];
477
  /** Soft failure: prior signal in the turn; model should continue, not stop. */
478
  softFailure?: boolean;
 
 
479
  }): string {
480
  const {
481
  fn,
@@ -485,13 +527,14 @@ function buildLlmToolContent(opts: {
485
  thrownReason,
486
  jobPreferences,
487
  softFailure,
 
488
  } = opts;
489
  const jobs = result.jobs ?? [];
490
  const jobCount = jobs.length;
491
  const hasPayload = typeof result.payload !== "undefined";
492
  const payloadForLlm =
493
  fn === "discover_jobs" && hasPayload ?
494
- discoverJobsPayloadForLlm(result.payload, jobs, jobPreferences)
495
  : result.payload;
496
 
497
  let grounding: string;
@@ -615,7 +658,8 @@ async function resolveAgentLabAdmin(userId: string): Promise<boolean> {
615
  const MISSION_REMINDER =
616
  "[MISSION REMINDER: Specialist job search — work-from-anywhere, visa-sponsored, and eligible restricted remote. " +
617
  "Quote only listings from tool results. " +
618
- "discover_jobs already ran internal pivots: do not call it again this turn for the same intent unless a system nudge asks for recency=\"48h\". " +
 
619
  "On timeout: present partials and offer run_batch_search (do not auto-call it). " +
620
  "On tool error: say search is unavailable and stop. " +
621
  "On discover_jobs success with zero listings: say nothing matched recently; you may offer batch or 48h — do not say search is unavailable. " +
@@ -704,9 +748,10 @@ function buildSystemPrompt(opts: {
704
  "",
705
  "EXECUTION RULES (non-negotiable):",
706
  "1. In execution steps, act immediately with a tool call. Do not narrate what you are about to search.",
707
- "2. discover_jobs is the primary search tool. One call per intent per turn (it already runs internal pivots). For a normal profile search, call discover_jobs first — do not plan run_batch_search as step 1 unless the user explicitly asked for a deep/background/batch search.",
708
- "2a. Use your reasoning to provide 2–4 distinct searchAngles with the call. Ground them in the profile and make one ATS-first; the tool safely compiles them and falls back deterministically if omitted.",
709
- "3. After discover_jobs: if strongMatchCount > 0, present results grouped by opportunity path when sectionCounts shows multiple paths and quote matches verbatim with URLs. If 0 and internalPivotsExhausted, or if timedOut, show any partials and offer (do not automatically call) run_batch_search. If the tool returns an error, state that search is temporarily unavailable and stop: do not retry discover_jobs or call run_batch_search in that turn. If the tool succeeds with zero listings (no error), say nothing matched in the last 48 hours and you may offer batch — do not claim search is unavailable.",
 
710
  "4. Do not call score_job on discover_jobs results (already scored). Do not call research_company on those results unless the user named a company.",
711
  "5. remember: call when user rejects a company or states a preference. update_profile: call when user corrects role/stack.",
712
  "6. get_shortlist: call when user asks for saved jobs. research_company: only when the user named a company — never as a follow-up after discover_jobs.",
@@ -1084,6 +1129,8 @@ export async function runAgentConversation(opts: {
1084
  let finalResultSummary: string | undefined;
1085
  let budgetExhausted = false;
1086
  let autoCycleCount = 0;
 
 
1087
  let resumeAttached = false;
1088
  const verboseLogging = isLabInferenceLoggingEnabled();
1089
 
@@ -1648,12 +1695,15 @@ export async function runAgentConversation(opts: {
1648
  } catch {
1649
  /* default */
1650
  }
 
1651
  const policy = evaluateDiscoverOutcomePolicy({
1652
  totalDiscovered,
1653
  totalScored,
 
1654
  timedOut: p?.timedOut === true,
1655
  backgroundContinued: p?.backgroundContinued === true,
1656
  stoppedBecause: typeof p?.stoppedBecause === "string" ? p.stoppedBecause : undefined,
 
1657
  toolCallsUsed,
1658
  maxToolCalls: MAX_TOOL_CALLS,
1659
  autoCycleCount,
@@ -1665,6 +1715,12 @@ export async function runAgentConversation(opts: {
1665
  type: "progress",
1666
  text: "No scored matches yet. Widening to the last 48 hours and retrying discovery…",
1667
  });
 
 
 
 
 
 
1668
  } else if (policy.action === "await_resume") {
1669
  preferAwaitResumeStop = true;
1670
  }
@@ -2063,8 +2119,22 @@ export async function runAgentConversation(opts: {
2063
  thrownReason: value.thrownReason,
2064
  jobPreferences: profile.jobPreferences,
2065
  softFailure,
 
 
2066
  }),
2067
  });
 
 
 
 
 
 
 
 
 
 
 
 
2068
  }
2069
 
2070
  return skippedForBudget;
 
91
  evaluateDiscoverOutcomePolicy,
92
  humanizeToolError,
93
  isColloquialResumeMessage,
94
+ retryStrategyUserLine,
95
  shouldTreatAsHardSearchFailure,
96
  } from "@/lib/agent-chat/autonomy-policy";
97
+ import { avenueObservationForLlm, type AvenueObservation } from "@/lib/agent-chat/discovery-observations";
98
 
99
  const MAX_TOOL_CALLS = 10;
100
  const MAX_HISTORY = 24;
 
178
  "## Tool grounding (critical)",
179
  "You are a tool-calling agent. Job listings in your responses must come exclusively from tool results.",
180
  "Never invent job titles, companies, or URLs. If a tool returns nothing, say so.",
181
+ "Nested research: OUTER = where to look (avenue); INNER = how to look within that avenue (discover_jobs adapts angles from results).",
182
+ "One discover_jobs call = one avenue. searchAngles are optional hypotheses for the inner loop — not a fixed playlist.",
183
+ "After an avenue returns, compare avenueSummaries / avenueObservation. If the approach is weak, call discover_jobs again with a fundamentally different avenue (prefer seedFromRunId). Do not clone the same intent.",
184
+ "If Strong matches already meet the bar, present them — do not churn avenues.",
185
+ "Outcome policy may nudge recency=\"48h\" or a different avenue; follow those system lines when present.",
186
  "For ordinary \"find roles for me\" / profile searches, call discover_jobs first. Do not start with run_batch_search unless the user asked for a deeper background/batch search or discover already returned empty/timeout.",
187
+ "Do not write raw site: queries or provider payloads — planners compile safe queries.",
188
  "After a timeout, show partials and offer a background search; do not retry discovery in the same turn.",
189
  "If a search tool errors, say search is temporarily unavailable and stop. Do not retry discover_jobs or call run_batch_search in that turn.",
190
  "If discover_jobs succeeds with zero listings (no error), say nothing was found in the last 48 hours honestly and you may offer batch search — do not say search is unavailable.",
 
435
  payload: unknown,
436
  jobs?: AgentJobCard[],
437
  prefs?: UserProfile["jobPreferences"],
438
+ priorAvenueSummaries?: AvenueObservation[],
439
  ): Record<string, unknown> | undefined {
440
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined;
441
  const p = payload as Record<string, unknown>;
 
448
  {
449
  dominantFailureReason: (lr as Record<string, unknown>).dominantFailureReason,
450
  suggestedPivot: (lr as Record<string, unknown>).suggestedPivot,
451
+ strongMatchCount: (lr as Record<string, unknown>).strongMatchCount,
452
+ possibleMatchCount: (lr as Record<string, unknown>).possibleMatchCount,
453
+ lowScoreCount: (lr as Record<string, unknown>).lowScoreCount,
454
  }
455
  : undefined;
456
  const sectionCounts =
457
  jobs?.length ? countJobsByOpportunitySection(jobs, prefs) : undefined;
458
+
459
+ let avenueObservation: Record<string, unknown> | undefined;
460
+ if (p.avenueObservation && typeof p.avenueObservation === "object" && !Array.isArray(p.avenueObservation)) {
461
+ try {
462
+ avenueObservation = avenueObservationForLlm(p.avenueObservation as AvenueObservation);
463
+ } catch {
464
+ avenueObservation = undefined;
465
+ }
466
+ }
467
+
468
+ const avenueSummaries = [
469
+ ...(priorAvenueSummaries ?? []).map((a) => avenueObservationForLlm(a)),
470
+ ...(avenueObservation ? [avenueObservation] : []),
471
+ ];
472
+
473
+ const topFromJobs =
474
+ (jobs ?? [])
475
+ .filter((j) => j.verdict === "Strong Match" || j.verdict === "Possible")
476
+ .slice(0, 6)
477
+ .map((j) => ({
478
+ title: j.title,
479
+ ...(j.company ? { company: j.company } : {}),
480
+ verdict: j.verdict,
481
+ }));
482
+
483
  return {
484
  totalDiscovered: p.totalDiscovered,
485
  totalScored: p.totalScored,
 
499
  stoppedBecause: p.stoppedBecause,
500
  internalPivotsExhausted: p.internalPivotsExhausted,
501
  lastReflection: reflectionSummary,
502
+ ...(avenueObservation ? { avenueObservation } : {}),
503
+ ...(avenueSummaries.length ? { avenueSummaries } : {}),
504
+ ...(Array.isArray(p.strategyTrace) ? { strategyTrace: p.strategyTrace } : {}),
505
+ ...(topFromJobs.length ? { topListings: topFromJobs } : {}),
506
+ searchRunId: p.searchRunId,
507
  };
508
  }
509
 
 
516
  jobPreferences?: UserProfile["jobPreferences"];
517
  /** Soft failure: prior signal in the turn; model should continue, not stop. */
518
  softFailure?: boolean;
519
+ /** Prior avenues this turn (outer reflection). */
520
+ priorAvenueSummaries?: AvenueObservation[];
521
  }): string {
522
  const {
523
  fn,
 
527
  thrownReason,
528
  jobPreferences,
529
  softFailure,
530
+ priorAvenueSummaries,
531
  } = opts;
532
  const jobs = result.jobs ?? [];
533
  const jobCount = jobs.length;
534
  const hasPayload = typeof result.payload !== "undefined";
535
  const payloadForLlm =
536
  fn === "discover_jobs" && hasPayload ?
537
+ discoverJobsPayloadForLlm(result.payload, jobs, jobPreferences, priorAvenueSummaries)
538
  : result.payload;
539
 
540
  let grounding: string;
 
658
  const MISSION_REMINDER =
659
  "[MISSION REMINDER: Specialist job search — work-from-anywhere, visa-sponsored, and eligible restricted remote. " +
660
  "Quote only listings from tool results. " +
661
+ "Nested research: each discover_jobs is one avenue (inner adapt already ran). " +
662
+ "If that avenue is weak, you may start a different avenue with a new intent — not a clone — especially when outcome policy asks. " +
663
  "On timeout: present partials and offer run_batch_search (do not auto-call it). " +
664
  "On tool error: say search is unavailable and stop. " +
665
  "On discover_jobs success with zero listings: say nothing matched recently; you may offer batch or 48h — do not say search is unavailable. " +
 
748
  "",
749
  "EXECUTION RULES (non-negotiable):",
750
  "1. In execution steps, act immediately with a tool call. Do not narrate what you are about to search.",
751
+ "2. discover_jobs is the primary search tool. One call = one research avenue (inner loop adapts angles from results). For a normal profile search, call discover_jobs first — do not plan run_batch_search as step 1 unless the user explicitly asked for a deep/background/batch search.",
752
+ "2a. Decide a broad avenue (where to look). Optionally pass 2–4 searchAngles as hypotheses for the inner loop — not a fixed playlist. Ground them in the profile; make one ATS-first when relevant. Do not write raw site: queries.",
753
+ "2b. After an avenue returns: if Strong matches meet the bar, present them. If the avenue is weak/exhausted, you may call discover_jobs again with a fundamentally different avenue (prefer seedFromRunId). Compare avenueSummaries. Outcome policy may nudge a strategy switch or recency=\"48h\".",
754
+ "3. After discover_jobs: if strongMatchCount > 0, present results grouped by opportunity path when sectionCounts shows multiple paths and quote matches verbatim with URLs. If 0 and internalPivotsExhausted, or if timedOut, show any partials and offer (do not automatically call) run_batch_search — unless outcome policy asks for another avenue or 48h. If the tool returns an error, state that search is temporarily unavailable and stop: do not retry discover_jobs or call run_batch_search in that turn. If the tool succeeds with zero listings (no error), say nothing matched in the last 48 hours and you may offer batch — do not claim search is unavailable.",
755
  "4. Do not call score_job on discover_jobs results (already scored). Do not call research_company on those results unless the user named a company.",
756
  "5. remember: call when user rejects a company or states a preference. update_profile: call when user corrects role/stack.",
757
  "6. get_shortlist: call when user asks for saved jobs. research_company: only when the user named a company — never as a follow-up after discover_jobs.",
 
1129
  let finalResultSummary: string | undefined;
1130
  let budgetExhausted = false;
1131
  let autoCycleCount = 0;
1132
+ /** AvenueObservations accumulated this turn for outer reflection. */
1133
+ const turnAvenueSummaries: AvenueObservation[] = [];
1134
  let resumeAttached = false;
1135
  const verboseLogging = isLabInferenceLoggingEnabled();
1136
 
 
1695
  } catch {
1696
  /* default */
1697
  }
1698
+ const strongFromPayload = Array.isArray(p?.strongMatches) ? p.strongMatches.length : 0;
1699
  const policy = evaluateDiscoverOutcomePolicy({
1700
  totalDiscovered,
1701
  totalScored,
1702
+ strongMatchCount: strongFromPayload,
1703
  timedOut: p?.timedOut === true,
1704
  backgroundContinued: p?.backgroundContinued === true,
1705
  stoppedBecause: typeof p?.stoppedBecause === "string" ? p.stoppedBecause : undefined,
1706
+ internalPivotsExhausted: p?.internalPivotsExhausted === true,
1707
  toolCallsUsed,
1708
  maxToolCalls: MAX_TOOL_CALLS,
1709
  autoCycleCount,
 
1715
  type: "progress",
1716
  text: "No scored matches yet. Widening to the last 48 hours and retrying discovery…",
1717
  });
1718
+ } else if (policy.action === "retry_strategy") {
1719
+ pendingAutoCycleNudge = retryStrategyUserLine(observedSearchRunId);
1720
+ emit({
1721
+ type: "progress",
1722
+ text: "This search avenue looks thin. Trying a different approach…",
1723
+ });
1724
  } else if (policy.action === "await_resume") {
1725
  preferAwaitResumeStop = true;
1726
  }
 
2119
  thrownReason: value.thrownReason,
2120
  jobPreferences: profile.jobPreferences,
2121
  softFailure,
2122
+ priorAvenueSummaries:
2123
+ value.fn === "discover_jobs" ? [...turnAvenueSummaries] : undefined,
2124
  }),
2125
  });
2126
+
2127
+ if (
2128
+ value.fn === "discover_jobs" &&
2129
+ !value.result.error &&
2130
+ !value.toolThrewOrTimedOut
2131
+ ) {
2132
+ const av = (value.result.payload as { avenueObservation?: AvenueObservation } | undefined)
2133
+ ?.avenueObservation;
2134
+ if (av && typeof av === "object" && typeof av.approach === "string") {
2135
+ turnAvenueSummaries.push(av);
2136
+ }
2137
+ }
2138
  }
2139
 
2140
  return skippedForBudget;
lib/agent-chat/tools-schema.ts CHANGED
@@ -45,18 +45,19 @@ export const AGENT_TOOL_DEFINITIONS: Array<{
45
  name: "discover_jobs",
46
  description: [
47
  "Primary discovery engine for finding work-from-anywhere, visa-sponsored, and eligible restricted-remote tech roles.",
48
- "Discovers AND scores job listings in one operation via consolidated remote-job discovery (curated ATS inventory + open-web ATS search), with internal angle pivots and profile vector pre-filter.",
49
  "Returns only listings that pass the user's accessibility preferences and lane-specific scoring gates. Do not call score_job after this tool. Do not call research_company unless the user named a company.",
50
  "",
51
  "Inputs:",
52
- "- intent: Natural language query (e.g. 'senior frontend roles'). Do not say 'remote' (it is strictly enforced automatically).",
53
  "- sources: Optional array of site hosts, company names, or ATS URLs to prioritize.",
54
- "- searchAngles: When inference is available, provide 2–4 genuinely distinct profile-aware search angles. Include ATS-first plus work-from-anywhere, visa, or adjacent-skill angles as relevant. These are strategy inputs, not raw search operators.",
55
  "- recency: '48h' (default) or '24h' (fast). Do not request a week or month.",
56
  "",
57
  "Notes:",
58
  "- Always try this first before run_batch_search.",
59
- "- Internal pivots already ran — do not call discover_jobs again for the same intent this turn unless outcome policy asks for a different angle.",
 
60
  "- If it returns nothing or times out, present what you have and offer (do not auto-call) run_batch_search.",
61
  "- Listings are scored from full pages when needed and classified by opportunity path.",
62
  "- Optional seedFromRunId reuses discovered/scored URLs from a prior durable search run (cheaper resume).",
@@ -104,7 +105,7 @@ export const AGENT_TOOL_DEFINITIONS: Array<{
104
  required: ["intent", "focus"],
105
  },
106
  description:
107
- "Best-effort human-reasoned strategy. If omitted, deterministic reflection still completes the search.",
108
  },
109
  recency: {
110
  type: "string",
 
45
  name: "discover_jobs",
46
  description: [
47
  "Primary discovery engine for finding work-from-anywhere, visa-sponsored, and eligible restricted-remote tech roles.",
48
+ "Discovers AND scores job listings in one operation via consolidated remote-job discovery (curated ATS inventory + open-web ATS search), with an inner loop that adapts angles from result quality, plus profile vector pre-filter.",
49
  "Returns only listings that pass the user's accessibility preferences and lane-specific scoring gates. Do not call score_job after this tool. Do not call research_company unless the user named a company.",
50
  "",
51
  "Inputs:",
52
+ "- intent: Natural language query for this research avenue (e.g. 'senior frontend roles'). Do not say 'remote' (it is strictly enforced automatically).",
53
  "- sources: Optional array of site hosts, company names, or ATS URLs to prioritize.",
54
+ "- searchAngles: Optional hypotheses for the INNER loop (how to look within this avenue). Not a fixed playlist — the tool adapts from results. Include ATS-first / WFA / visa / adjacent-skill when relevant.",
55
  "- recency: '48h' (default) or '24h' (fast). Do not request a week or month.",
56
  "",
57
  "Notes:",
58
  "- Always try this first before run_batch_search.",
59
+ "- One call = one OUTER avenue. After it returns, compare avenueObservation / avenueSummaries; you may call discover_jobs again with a fundamentally different approach when the avenue is weak (prefer seedFromRunId). Do not clone the same intent.",
60
+ "- If Strong matches already meet the bar, present them instead of churning avenues.",
61
  "- If it returns nothing or times out, present what you have and offer (do not auto-call) run_batch_search.",
62
  "- Listings are scored from full pages when needed and classified by opportunity path.",
63
  "- Optional seedFromRunId reuses discovered/scored URLs from a prior durable search run (cheaper resume).",
 
105
  required: ["intent", "focus"],
106
  },
107
  description:
108
+ "Optional inner-loop hypotheses for this avenue (not a fixed playlist). If omitted, deterministic + reflective adaptation still completes the search.",
109
  },
110
  recency: {
111
  type: "string",
package.json CHANGED
@@ -20,7 +20,7 @@
20
  "test:agent-persist": "tsx --tsconfig tsconfig.json lib/agent-run-persist.selftest.ts",
21
  "test:session-context": "tsx --tsconfig tsconfig.json lib/agent-chat/session-context.selftest.ts && tsx --tsconfig tsconfig.json lib/normalize-job-url.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/session-listings-store.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/session-transcript-store.selftest.ts",
22
  "test:subscription": "tsx --tsconfig tsconfig.json lib/subscription.selftest.ts",
23
- "test:autonomy-policy": "tsx --tsconfig tsconfig.json lib/agent-chat/autonomy-policy.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/parse-embedded-tool-calls.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/deterministic-discover-fallback.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/suggested-intents.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/turn-failure-recovery.selftest.ts",
24
  "test:discover-jobs": "tsx --tsconfig tsconfig.json lib/agent-chat/discover-jobs-tool.selftest.ts",
25
  "test:discovery": "tsx --tsconfig tsconfig.json lib/discovery/discovery.selftest.ts && tsx --tsconfig tsconfig.json lib/discovery/jsearch.selftest.ts && tsx --tsconfig tsconfig.json lib/discover-pre-llm-gate.selftest.ts && tsx --tsconfig tsconfig.json lib/seen-listings.selftest.ts && tsx --tsconfig tsconfig.json lib/discover-jobs-lab-debug.selftest.ts",
26
  "bench:discovery": "tsx --tsconfig tsconfig.json lib/discovery/benchmark.ts",
 
20
  "test:agent-persist": "tsx --tsconfig tsconfig.json lib/agent-run-persist.selftest.ts",
21
  "test:session-context": "tsx --tsconfig tsconfig.json lib/agent-chat/session-context.selftest.ts && tsx --tsconfig tsconfig.json lib/normalize-job-url.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/session-listings-store.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/session-transcript-store.selftest.ts",
22
  "test:subscription": "tsx --tsconfig tsconfig.json lib/subscription.selftest.ts",
23
+ "test:autonomy-policy": "tsx --tsconfig tsconfig.json lib/agent-chat/autonomy-policy.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/parse-embedded-tool-calls.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/deterministic-discover-fallback.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/suggested-intents.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/turn-failure-recovery.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/discovery-observations.selftest.ts && tsx --tsconfig tsconfig.json lib/agent-chat/plan-next-search-angle.selftest.ts",
24
  "test:discover-jobs": "tsx --tsconfig tsconfig.json lib/agent-chat/discover-jobs-tool.selftest.ts",
25
  "test:discovery": "tsx --tsconfig tsconfig.json lib/discovery/discovery.selftest.ts && tsx --tsconfig tsconfig.json lib/discovery/jsearch.selftest.ts && tsx --tsconfig tsconfig.json lib/discover-pre-llm-gate.selftest.ts && tsx --tsconfig tsconfig.json lib/seen-listings.selftest.ts && tsx --tsconfig tsconfig.json lib/discover-jobs-lab-debug.selftest.ts",
26
  "bench:discovery": "tsx --tsconfig tsconfig.json lib/discovery/benchmark.ts",