lynkr 9.14.2 → 9.14.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +10 -4
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/openai-router.js +125 -4
- package/src/api/providers-handler.js +16 -0
- package/src/api/router.js +67 -103
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/clients/databricks.js +285 -21
- package/src/clients/gpt-utils.js +83 -7
- package/src/clients/health-probe.js +157 -0
- package/src/clients/provider-capabilities.js +27 -0
- package/src/config/index.js +13 -1
- package/src/context/compression.js +127 -19
- package/src/dashboard/api.js +1 -0
- package/src/observability/metrics.js +52 -0
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +219 -136
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +62 -3
- package/src/routing/bandit.js +39 -0
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +37 -0
- package/src/routing/index.js +202 -86
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/tools/web-search-exec.js +182 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stuck-loop / no-progress detection (ROUTING-NOTES §4.10.5).
|
|
3
|
+
*
|
|
4
|
+
* The escalation ladder reacts to score drift, context overflow, risk
|
|
5
|
+
* keywords, and vision needs — but nothing watches for "the pinned model is
|
|
6
|
+
* thrashing": re-issuing the same tool call with the same input over and
|
|
7
|
+
* over, or repeating the same assistant text verbatim. A cheap-tier model
|
|
8
|
+
* stuck in that state burns turns indefinitely, because every frame of the
|
|
9
|
+
* loop is a mid-tool-exchange pin serve (which is unconditional — tool-call
|
|
10
|
+
* IDs aren't portable across providers, so switching mid-exchange would 400).
|
|
11
|
+
*
|
|
12
|
+
* The safe intervention is the one the pin path already uses for embedded
|
|
13
|
+
* text triggers: don't switch this turn — DROP THE PIN, so the next turn
|
|
14
|
+
* boundary re-routes fresh (and full routing, seeing the whole struggling
|
|
15
|
+
* conversation, escalates on its own signals).
|
|
16
|
+
*
|
|
17
|
+
* Detection is deliberately narrow to keep false positives near zero:
|
|
18
|
+
* - tool repetition: the last K assistant tool_use blocks are the SAME
|
|
19
|
+
* tool with the SAME input, K >= LYNKR_STUCK_TOOL_REPEATS (default 3).
|
|
20
|
+
* Agents legitimately re-run a tool (poll, retry-once); three identical
|
|
21
|
+
* consecutive calls is a loop.
|
|
22
|
+
* - text repetition: the last K assistant text blocks are identical after
|
|
23
|
+
* whitespace normalization, K >= LYNKR_STUCK_TEXT_REPEATS (default 3).
|
|
24
|
+
*
|
|
25
|
+
* Only the tail of the conversation is scanned (SCAN_WINDOW messages), so
|
|
26
|
+
* the check is O(1)-ish per request regardless of conversation length.
|
|
27
|
+
*
|
|
28
|
+
* @module routing/stuck-detector
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SCAN_WINDOW = 20;
|
|
32
|
+
|
|
33
|
+
function _toolRepeats() {
|
|
34
|
+
const n = Number.parseInt(process.env.LYNKR_STUCK_TOOL_REPEATS, 10);
|
|
35
|
+
return Number.isNaN(n) ? 3 : Math.max(2, n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _textRepeats() {
|
|
39
|
+
const n = Number.parseInt(process.env.LYNKR_STUCK_TEXT_REPEATS, 10);
|
|
40
|
+
return Number.isNaN(n) ? 3 : Math.max(2, n);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function _enabled() {
|
|
44
|
+
return process.env.LYNKR_STUCK_DETECTOR_ENABLED !== 'false';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract, oldest→newest, the assistant tool_use signatures and text blocks
|
|
49
|
+
* from the tail of the conversation.
|
|
50
|
+
*/
|
|
51
|
+
function _assistantTail(messages) {
|
|
52
|
+
const tail = messages.slice(-SCAN_WINDOW);
|
|
53
|
+
const toolSigs = [];
|
|
54
|
+
const texts = [];
|
|
55
|
+
for (const msg of tail) {
|
|
56
|
+
if (msg?.role !== 'assistant') continue;
|
|
57
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
58
|
+
for (const block of content) {
|
|
59
|
+
if (block?.type === 'tool_use') {
|
|
60
|
+
let inputSig;
|
|
61
|
+
try {
|
|
62
|
+
inputSig = JSON.stringify(block.input ?? null);
|
|
63
|
+
} catch {
|
|
64
|
+
inputSig = String(block.input);
|
|
65
|
+
}
|
|
66
|
+
toolSigs.push(`${block.name}::${inputSig}`);
|
|
67
|
+
} else if (block?.type === 'text' && typeof block.text === 'string') {
|
|
68
|
+
const normalized = block.text.replace(/\s+/g, ' ').trim();
|
|
69
|
+
if (normalized.length > 0) texts.push(normalized);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// String-content assistant messages count as text blocks too.
|
|
73
|
+
if (typeof msg.content === 'string') {
|
|
74
|
+
const normalized = msg.content.replace(/\s+/g, ' ').trim();
|
|
75
|
+
if (normalized.length > 0) texts.push(normalized);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { toolSigs, texts };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _trailingRun(items) {
|
|
82
|
+
if (items.length === 0) return 0;
|
|
83
|
+
const last = items[items.length - 1];
|
|
84
|
+
let run = 0;
|
|
85
|
+
for (let i = items.length - 1; i >= 0 && items[i] === last; i--) run++;
|
|
86
|
+
return run;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Detect a stuck loop in the conversation tail.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} payload — request payload with .messages
|
|
93
|
+
* @returns {{ stuck: boolean, reason?: 'tool_repetition'|'text_repetition',
|
|
94
|
+
* repeats?: number, signature?: string }}
|
|
95
|
+
*/
|
|
96
|
+
function detectStuckLoop(payload) {
|
|
97
|
+
if (!_enabled()) return { stuck: false };
|
|
98
|
+
const messages = payload?.messages;
|
|
99
|
+
if (!Array.isArray(messages) || messages.length < 4) return { stuck: false };
|
|
100
|
+
|
|
101
|
+
const { toolSigs, texts } = _assistantTail(messages);
|
|
102
|
+
|
|
103
|
+
const toolRun = _trailingRun(toolSigs);
|
|
104
|
+
if (toolRun >= _toolRepeats()) {
|
|
105
|
+
return {
|
|
106
|
+
stuck: true,
|
|
107
|
+
reason: 'tool_repetition',
|
|
108
|
+
repeats: toolRun,
|
|
109
|
+
// Truncated for logging — the full input may be huge or sensitive.
|
|
110
|
+
signature: toolSigs[toolSigs.length - 1].slice(0, 120),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const textRun = _trailingRun(texts);
|
|
115
|
+
if (textRun >= _textRepeats()) {
|
|
116
|
+
return {
|
|
117
|
+
stuck: true,
|
|
118
|
+
reason: 'text_repetition',
|
|
119
|
+
repeats: textRun,
|
|
120
|
+
signature: texts[texts.length - 1].slice(0, 120),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { stuck: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { detectStuckLoop };
|
package/src/routing/telemetry.js
CHANGED
|
@@ -175,6 +175,12 @@ function init() {
|
|
|
175
175
|
// providers that report none) — "not measured" is distinct from 0.
|
|
176
176
|
["cache_read_tokens", "INTEGER"],
|
|
177
177
|
["cache_creation_tokens", "INTEGER"],
|
|
178
|
+
// Off-policy evaluation — the bandit's context vector (12-dim JSON
|
|
179
|
+
// array) for the row's decision. Required by the doubly-robust
|
|
180
|
+
// estimator's regression term r̂(x, a); IPS/SNIPS work without it.
|
|
181
|
+
// NULL on rows where the bandit didn't run (deterministic decisions)
|
|
182
|
+
// or recorded before this column existed.
|
|
183
|
+
["context", "TEXT"],
|
|
178
184
|
];
|
|
179
185
|
for (const [col, type] of additiveCols) {
|
|
180
186
|
if (!existingCols.has(col)) {
|
|
@@ -237,7 +243,7 @@ function record(data) {
|
|
|
237
243
|
retry_count, circuit_breaker_state, quality_score, tokens_per_second,
|
|
238
244
|
cost_efficiency, request_text, response_text,
|
|
239
245
|
base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
|
|
240
|
-
cache_decision, cache_read_tokens, cache_creation_tokens
|
|
246
|
+
cache_decision, cache_read_tokens, cache_creation_tokens, context
|
|
241
247
|
) VALUES (
|
|
242
248
|
@request_id, @session_id, @timestamp, @complexity_score, @tier,
|
|
243
249
|
@agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
|
|
@@ -246,7 +252,7 @@ function record(data) {
|
|
|
246
252
|
@retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
|
|
247
253
|
@cost_efficiency, @request_text, @response_text,
|
|
248
254
|
@base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
|
|
249
|
-
@cache_decision, @cache_read_tokens, @cache_creation_tokens
|
|
255
|
+
@cache_decision, @cache_read_tokens, @cache_creation_tokens, @context
|
|
250
256
|
)`
|
|
251
257
|
);
|
|
252
258
|
if (!insert) return;
|
|
@@ -299,6 +305,9 @@ function record(data) {
|
|
|
299
305
|
: JSON.stringify(data.cache_decision)),
|
|
300
306
|
cache_read_tokens: data.cache_read_tokens ?? null,
|
|
301
307
|
cache_creation_tokens: data.cache_creation_tokens ?? null,
|
|
308
|
+
context: data.context == null
|
|
309
|
+
? null
|
|
310
|
+
: (typeof data.context === "string" ? data.context : JSON.stringify(data.context)),
|
|
302
311
|
});
|
|
303
312
|
} catch (err) {
|
|
304
313
|
logger.debug({ err: err.message }, "Telemetry record failed");
|
package/src/server.js
CHANGED
|
@@ -80,7 +80,11 @@ function createApp() {
|
|
|
80
80
|
|
|
81
81
|
// Phase 6.1 — per-tenant routing policies (LYNKR-Tenant-Id header).
|
|
82
82
|
// Runs before message handling so res.locals.tenantPolicy is populated.
|
|
83
|
-
|
|
83
|
+
// Must cover every AGENT_ENDPOINT, not just /v1/messages — mounting on a
|
|
84
|
+
// single path silently exempts OpenAI-compat clients from tenant policy
|
|
85
|
+
// (the same failure mode the comment above already warns about for other
|
|
86
|
+
// guards; see issue #100).
|
|
87
|
+
for (const p of AGENT_ENDPOINTS) app.use(p, tenantMiddleware);
|
|
84
88
|
|
|
85
89
|
// Phase 6.2 — hierarchical budget enforcement (LYNKR_BUDGET_ENFORCER=false to disable).
|
|
86
90
|
for (const p of AGENT_ENDPOINTS) app.use(p, budgetEnforcer);
|
|
@@ -105,7 +109,8 @@ function createApp() {
|
|
|
105
109
|
|
|
106
110
|
app.get("/metrics/circuit-breakers", (req, res) => {
|
|
107
111
|
const registry = getCircuitBreakerRegistry();
|
|
108
|
-
|
|
112
|
+
const { getHealthProber } = require("./clients/health-probe");
|
|
113
|
+
res.json({ breakers: registry.getAll(), healthProbe: getHealthProber().getStatus() });
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
app.get("/metrics/load-shedding", (req, res) => {
|
|
@@ -124,13 +129,23 @@ function createApp() {
|
|
|
124
129
|
|
|
125
130
|
app.get("/metrics/semantic-cache", (req, res) => {
|
|
126
131
|
const { getSemanticCache, isSemanticCacheEnabled } = require("./cache/semantic");
|
|
132
|
+
const { getEmbeddingStatus } = require("./cache/embeddings");
|
|
127
133
|
if (!isSemanticCacheEnabled()) {
|
|
128
134
|
return res.json({ enabled: false, message: "Semantic cache not enabled" });
|
|
129
135
|
}
|
|
130
136
|
const cache = getSemanticCache();
|
|
131
|
-
|
|
137
|
+
// embeddings.providerAvailable === false means matches are currently
|
|
138
|
+
// served from the non-semantic hash fallback — cache "hits" during a
|
|
139
|
+
// degraded window are approximate, not semantic.
|
|
140
|
+
res.json({ enabled: true, embeddings: getEmbeddingStatus(), ...cache.getStats() });
|
|
132
141
|
});
|
|
133
142
|
|
|
143
|
+
// MCP broker — exposes configured MCP servers to other clients over HTTP.
|
|
144
|
+
// Off by default; requires LYNKR_MCP_BROKER_ENABLED + a bearer token.
|
|
145
|
+
// Mounted BEFORE the main router so /v1/mcp/* never falls through to the
|
|
146
|
+
// OpenAI-compat surface.
|
|
147
|
+
app.use(require('./api/mcp-broker'));
|
|
148
|
+
|
|
134
149
|
app.use(router);
|
|
135
150
|
|
|
136
151
|
app.use('/dashboard', require('./dashboard/router'));
|
|
@@ -181,6 +196,29 @@ async function start() {
|
|
|
181
196
|
|
|
182
197
|
const app = createApp();
|
|
183
198
|
|
|
199
|
+
// Synthetic circuit-breaker health probing — recovers open circuits with a
|
|
200
|
+
// cheap background probe instead of letting the next live user request pay
|
|
201
|
+
// for testing a dead provider. Zero-cost while all breakers are closed.
|
|
202
|
+
try {
|
|
203
|
+
const { getHealthProber } = require("./clients/health-probe");
|
|
204
|
+
const prober = getHealthProber();
|
|
205
|
+
prober.start();
|
|
206
|
+
getShutdownManager().onShutdown(() => prober.stop());
|
|
207
|
+
} catch (err) {
|
|
208
|
+
logger.warn({ err: err.message }, "Health prober failed to start, circuit recovery falls back to live-request probing");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// OTel GenAI metrics export — no-op unless OTEL_EXPORTER_OTLP_ENDPOINT
|
|
212
|
+
// (or LYNKR_OTEL_ENDPOINT) is set. Zero-dependency OTLP/HTTP push.
|
|
213
|
+
try {
|
|
214
|
+
const { getOtelExporter } = require("./observability/otel");
|
|
215
|
+
const otel = getOtelExporter();
|
|
216
|
+
otel.start();
|
|
217
|
+
getShutdownManager().onShutdown(() => otel.stop());
|
|
218
|
+
} catch (err) {
|
|
219
|
+
logger.warn({ err: err.message }, "OTel exporter failed to start");
|
|
220
|
+
}
|
|
221
|
+
|
|
184
222
|
// Wait for Ollama if it's the configured provider or referenced in tier config
|
|
185
223
|
const provider = config.modelProvider?.type?.toLowerCase();
|
|
186
224
|
if (provider === "ollama" || config.tiersReferenceOllama()) {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side web_search / web_fetch execution — narrowly scoped resurrection.
|
|
3
|
+
*
|
|
4
|
+
* Context: src/tools/web.js used to implement this (SearXNG-backed search,
|
|
5
|
+
* host-allowlisted fetch) and was deleted 2026-07-22 in commit b32e988,
|
|
6
|
+
* "Remove server-mode tool execution: Lynkr always forwards tool calls to
|
|
7
|
+
* the client." That was a deliberate architecture pivot, not a bug — Lynkr
|
|
8
|
+
* stopped executing ANY tool itself.
|
|
9
|
+
*
|
|
10
|
+
* This does NOT reintroduce general server-mode tool execution. It only
|
|
11
|
+
* auto-resolves `web_search`/`web_fetch` calls, and ONLY when the calling
|
|
12
|
+
* client is unrecognized by src/routing/client-profiles.js's detectClient()
|
|
13
|
+
* — i.e. Lynkr has no signal the caller can fulfill the call itself.
|
|
14
|
+
* Known harnesses (Claude Code / Claude Desktop's shared agent-sdk — both
|
|
15
|
+
* present as `claude-cli/...`, Cursor, goose, Codex) already execute these
|
|
16
|
+
* client-side today and are never routed through this module; see the call
|
|
17
|
+
* site in src/orchestrator/index.js's runAgentLoop.
|
|
18
|
+
*
|
|
19
|
+
* @module tools/web-search-exec
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const config = require("../config");
|
|
23
|
+
const logger = require("../logger");
|
|
24
|
+
|
|
25
|
+
const AUTO_RESOLVABLE_NAMES = new Set(["web_search", "websearch", "web_fetch", "webfetch"]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} name
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
function isAutoResolvable(name) {
|
|
32
|
+
return AUTO_RESOLVABLE_NAMES.has(String(name || "").toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True only when every call in this batch is one we can resolve ourselves —
|
|
37
|
+
* a mixed batch (one auto-resolvable + one arbitrary client tool) falls
|
|
38
|
+
* through to the normal "forward to client" path untouched, since we can't
|
|
39
|
+
* partially resolve a batch the model expects answered together.
|
|
40
|
+
*
|
|
41
|
+
* @param {Array} toolCalls - normalized {id, function:{name}} or {name} calls
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function canAutoResolveAll(toolCalls) {
|
|
45
|
+
return Array.isArray(toolCalls) && toolCalls.length > 0
|
|
46
|
+
&& toolCalls.every((tc) => isAutoResolvable(tc?.function?.name ?? tc?.name));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isHostAllowed(urlStr) {
|
|
50
|
+
if (config.webSearch?.allowAllHosts) return true;
|
|
51
|
+
try {
|
|
52
|
+
const host = new URL(urlStr).hostname.toLowerCase();
|
|
53
|
+
const allowed = config.webSearch?.allowedHosts;
|
|
54
|
+
return Array.isArray(allowed) && allowed.includes(host);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function _withTimeout(fn, timeoutMs) {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
63
|
+
try {
|
|
64
|
+
return await fn(controller.signal);
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* SearXNG-backed search. Retries transient failures per config.webSearch
|
|
72
|
+
* (retryEnabled/maxRetries) — mirrors the retry policy the deleted web.js
|
|
73
|
+
* had, since a single dropped connection to a local sidecar shouldn't fail
|
|
74
|
+
* the whole tool call.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} query
|
|
77
|
+
* @returns {Promise<Object>} { query, results: [{title,url,snippet}] } or { error }
|
|
78
|
+
*/
|
|
79
|
+
async function searchWeb(query) {
|
|
80
|
+
const endpoint = config.webSearch?.endpoint || "http://localhost:8888/search";
|
|
81
|
+
const timeoutMs = config.webSearch?.timeoutMs || 10000;
|
|
82
|
+
const maxRetries = config.webSearch?.retryEnabled ? (config.webSearch?.maxRetries ?? 2) : 0;
|
|
83
|
+
const url = `${endpoint}?q=${encodeURIComponent(query || "")}&format=json`;
|
|
84
|
+
|
|
85
|
+
let lastErr = null;
|
|
86
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
87
|
+
try {
|
|
88
|
+
const res = await _withTimeout(
|
|
89
|
+
(signal) => fetch(url, { signal }),
|
|
90
|
+
timeoutMs,
|
|
91
|
+
);
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
lastErr = `search backend returned HTTP ${res.status}`;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const json = await res.json();
|
|
97
|
+
const results = (Array.isArray(json.results) ? json.results : [])
|
|
98
|
+
.slice(0, 8)
|
|
99
|
+
.map((r) => ({ title: r.title, url: r.url, snippet: r.content }));
|
|
100
|
+
return { query, results };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
lastErr = err.message;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
logger.warn({ query, endpoint, err: lastErr }, "[web-search-exec] search failed after retries");
|
|
106
|
+
return { query, error: `search failed: ${lastErr}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Generic URL fetch, host-allowlisted per config.webSearch (allowAllHosts /
|
|
111
|
+
* allowedHosts), truncated per config.webSearch.bodyPreviewMax.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} targetUrl
|
|
114
|
+
* @returns {Promise<Object>}
|
|
115
|
+
*/
|
|
116
|
+
async function fetchUrl(targetUrl) {
|
|
117
|
+
if (!targetUrl) return { error: "no url provided" };
|
|
118
|
+
if (!isHostAllowed(targetUrl)) {
|
|
119
|
+
logger.warn({ url: targetUrl }, "[web-search-exec] fetch blocked — host not allowed");
|
|
120
|
+
return { url: targetUrl, error: "host not in allowedHosts (WEB_SEARCH_ALLOW_ALL=false)" };
|
|
121
|
+
}
|
|
122
|
+
const maxPreview = config.webSearch?.bodyPreviewMax || 10000;
|
|
123
|
+
const timeoutMs = config.webSearch?.timeoutMs || 10000;
|
|
124
|
+
try {
|
|
125
|
+
const res = await _withTimeout(
|
|
126
|
+
(signal) => fetch(targetUrl, { signal }),
|
|
127
|
+
timeoutMs,
|
|
128
|
+
);
|
|
129
|
+
const text = await res.text();
|
|
130
|
+
return {
|
|
131
|
+
url: targetUrl,
|
|
132
|
+
status: res.status,
|
|
133
|
+
content: text.slice(0, maxPreview),
|
|
134
|
+
truncated: text.length > maxPreview,
|
|
135
|
+
};
|
|
136
|
+
} catch (err) {
|
|
137
|
+
logger.warn({ url: targetUrl, err: err.message }, "[web-search-exec] fetch failed");
|
|
138
|
+
return { url: targetUrl, error: `fetch failed: ${err.message}` };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Execute every call in this (pre-checked via canAutoResolveAll) batch and
|
|
144
|
+
* append the matching assistant tool_use + user tool_result turns to
|
|
145
|
+
* `messages`, mirroring exactly what a real client round-trip would send
|
|
146
|
+
* back — so the next agent-loop iteration behaves identically to a normal
|
|
147
|
+
* client-fulfilled tool exchange, and the client never sees the exchange.
|
|
148
|
+
*
|
|
149
|
+
* @param {Array} toolCalls
|
|
150
|
+
* @param {Array} messages - mutated in place (push only, never rewritten)
|
|
151
|
+
*/
|
|
152
|
+
async function autoResolve(toolCalls, messages) {
|
|
153
|
+
const toolUseBlocks = [];
|
|
154
|
+
const toolResultBlocks = [];
|
|
155
|
+
|
|
156
|
+
for (const tc of toolCalls) {
|
|
157
|
+
const rawName = tc.function?.name ?? tc.name ?? "";
|
|
158
|
+
const name = String(rawName).toLowerCase();
|
|
159
|
+
let input = {};
|
|
160
|
+
try {
|
|
161
|
+
const raw = tc.function?.arguments ?? tc.input ?? {};
|
|
162
|
+
input = typeof raw === "string" ? JSON.parse(raw) : (raw || {});
|
|
163
|
+
} catch { /* leave input empty — result will just reflect an empty query/url */ }
|
|
164
|
+
|
|
165
|
+
toolUseBlocks.push({ type: "tool_use", id: tc.id, name: rawName, input });
|
|
166
|
+
|
|
167
|
+
const result = (name === "web_search" || name === "websearch")
|
|
168
|
+
? await searchWeb(input.query || input.q || "")
|
|
169
|
+
: await fetchUrl(input.url || "");
|
|
170
|
+
|
|
171
|
+
toolResultBlocks.push({
|
|
172
|
+
type: "tool_result",
|
|
173
|
+
tool_use_id: tc.id,
|
|
174
|
+
content: JSON.stringify(result),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
messages.push({ role: "assistant", content: toolUseBlocks });
|
|
179
|
+
messages.push({ role: "user", content: toolResultBlocks });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { canAutoResolveAll, autoResolve, searchWeb, fetchUrl, isAutoResolvable };
|