Spaces:
Running
Running
Cursor: Apply local changes for cloud agent
Browse files- docs/DISCOVERY.md +17 -3
- lib/agent-chat/autonomy-policy.selftest.ts +53 -0
- lib/agent-chat/autonomy-policy.ts +55 -1
- lib/agent-chat/discover-jobs-tool.ts +97 -9
- lib/agent-chat/discovery-observations.selftest.ts +134 -0
- lib/agent-chat/discovery-observations.ts +329 -0
- lib/agent-chat/plan-next-search-angle.selftest.ts +50 -0
- lib/agent-chat/plan-next-search-angle.ts +172 -0
- lib/agent-chat/run-conversation.ts +77 -7
- lib/agent-chat/tools-schema.ts +6 -5
- package.json +1 -1
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 |
-
##
|
| 78 |
|
| 79 |
-
|
| 80 |
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2426 |
-
|
| 2427 |
-
|
| 2428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2429 |
nextOpts = applyReasonedFocus(
|
| 2430 |
{
|
| 2431 |
progressMode: "continuation",
|
|
@@ -2433,20 +2492,24 @@ export async function runDiscoverJobsTool(opts: {
|
|
| 2433 |
pivot: "default",
|
| 2434 |
angleIndex: anglesRun,
|
| 2435 |
},
|
| 2436 |
-
|
| 2437 |
);
|
| 2438 |
-
|
|
|
|
|
|
|
|
|
|
| 2439 |
progress(
|
| 2440 |
rationale ?
|
| 2441 |
-
`
|
| 2442 |
-
: `
|
| 2443 |
);
|
| 2444 |
lastStrategy =
|
| 2445 |
-
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 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 |
-
"
|
|
|
|
| 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
|
| 708 |
-
"2a.
|
| 709 |
-
"
|
|
|
|
| 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
|
| 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:
|
| 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 |
-
"-
|
|
|
|
| 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 |
-
"
|
| 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",
|