@moguiyu/dsh-tavily 0.3.4 → 0.3.5
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 +8 -2
- package/package.json +14 -10
- package/src/tools.js +398 -33
- package/src/invariant.js +0 -18
package/README.md
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# @moguiyu/dsh-tavily
|
|
2
2
|
|
|
3
3
|
Everything in one package: the advanced Tavily model tools (`tavily_search`, `tavily_extract`,
|
|
4
|
-
`tavily_map`, `tavily_crawl`), the key/usage HTTP
|
|
5
|
-
page.
|
|
4
|
+
`tavily_map`, `tavily_crawl`, `tavily_research`, `tavily_get_research`), the key/usage HTTP
|
|
5
|
+
routes, and the browser card on the **Plugins** page.
|
|
6
6
|
|
|
7
7
|
- **`tavily_search`** — full Tavily surface (`max_results`, `search_depth`, `topic`, `days`,
|
|
8
8
|
`include_answer`, `include_raw_content`, `include_domains`, `exclude_domains`).
|
|
9
9
|
- **`tavily_extract` / `tavily_map` / `tavily_crawl`** — read a known URL's content, map a
|
|
10
10
|
site's links, or crawl a site and return its pages. Same key-rotation/failover as search.
|
|
11
|
+
- **`tavily_research` / `tavily_get_research`** — Tavily's Research endpoint: queue a task
|
|
12
|
+
(`model`: `mini` / `pro` / `auto`, plus `citation_format`, `output_length`, domain filters and an
|
|
13
|
+
optional `output_schema`), wait for it, and get a synthesized, cited report; the second tool
|
|
14
|
+
collects a task that outlived the first call. Research is billed per request and far above
|
|
15
|
+
search — **mini 4-110 credits, pro 15-250**, against 1-2 for a search — so it is a per-call
|
|
16
|
+
choice, never a default. `timeout_seconds: 0` queues the task and returns its id immediately.
|
|
11
17
|
- **Card** — key list, usage gauge, and the strategy selector. It registers into the Plugins
|
|
12
18
|
page's `plugins.bundle.config` slot, keyed `@moguiyu/dsh-tavily`, and needs DSH
|
|
13
19
|
**0.1.6-alpha.2 or newer** — the release that retired `settings.plugin.item` and moved plugin
|
package/package.json
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moguiyu/dsh-tavily",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Advanced Tavily model tools for DeepSeek Harness (tavily_search, tavily_extract, tavily_map, tavily_crawl) with multi-key rotation, failover and a per-account in-flight cap, plus a key and usage management card on the Plugins page. The built-in web_search is never replaced. | DSH Tavily 可选高级搜索工具:多 Key
|
|
3
|
+
"version": "0.3.5",
|
|
4
|
+
"description": "Advanced Tavily model tools for DeepSeek Harness (tavily_search, tavily_extract, tavily_map, tavily_crawl, tavily_research, tavily_get_research) with multi-key rotation, failover and a per-account in-flight cap, plus a key and usage management card on the Plugins page. The built-in web_search is never replaced. | DSH Tavily 可选高级搜索工具:多 Key 轮换、故障转移与按账号并发上限,另有 research 综合调研报告,插件配置页含密钥与用量管理卡片;内置 web_search 永不替换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
|
-
"./invariant": "./src/invariant.js",
|
|
10
9
|
"./client": "./lib/client.js",
|
|
11
|
-
"./src/*": "./src/*",
|
|
12
10
|
"./package.json": "./package.json"
|
|
13
11
|
},
|
|
14
12
|
"files": [
|
|
15
|
-
"src",
|
|
16
|
-
"
|
|
13
|
+
"src/index.js",
|
|
14
|
+
"src/tools.js",
|
|
15
|
+
"src/backend.js",
|
|
16
|
+
"src/lib.js",
|
|
17
|
+
"src/client.js",
|
|
18
|
+
"lib/client.js",
|
|
17
19
|
"cordis.patch.yml",
|
|
18
20
|
"README.md",
|
|
19
21
|
"LICENSE"
|
|
@@ -40,7 +42,9 @@
|
|
|
40
42
|
"0.1.6-alpha.2": "compatible",
|
|
41
43
|
"0.1.7-rc.2": "compatible",
|
|
42
44
|
"0.2.0-rc.1": "compatible",
|
|
43
|
-
"0.2.0-rc.2": "compatible"
|
|
45
|
+
"0.2.0-rc.2": "compatible",
|
|
46
|
+
"0.2.1-alpha.1": "compatible",
|
|
47
|
+
"0.2.1-alpha.2": "compatible"
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
50
|
"client": {
|
|
@@ -68,9 +72,9 @@
|
|
|
68
72
|
"directory": "packages/dsh-tavily"
|
|
69
73
|
},
|
|
70
74
|
"peerDependencies": {
|
|
71
|
-
"@deepseek-ai/cordis": "^4.0.1",
|
|
72
|
-
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0",
|
|
73
|
-
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0"
|
|
75
|
+
"@deepseek-ai/cordis": "^4.0.1 || ^4.0.5-0",
|
|
76
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0 || ^0.2.1-0",
|
|
77
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0 || ^0.2.1-0"
|
|
74
78
|
},
|
|
75
79
|
"devDependencies": {
|
|
76
80
|
"@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2"
|
package/src/tools.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tool half of `@moguiyu/dsh-tavily`: the advanced `tavily_search` model tool
|
|
3
|
-
*
|
|
4
|
-
* `tavily_crawl`)
|
|
5
|
-
*
|
|
2
|
+
* Tool half of `@moguiyu/dsh-tavily`: the advanced `tavily_search` model tool,
|
|
3
|
+
* the extra Tavily direct tools (`tavily_extract`, `tavily_map`,
|
|
4
|
+
* `tavily_crawl`), and the asynchronous Research pair (`tavily_research`,
|
|
5
|
+
* `tavily_get_research`). Keys resolve from the `TAVILY_API_KEYS` credential
|
|
6
|
+
* per call; rotation is round-robin with failover on every per-key refusal
|
|
6
7
|
* (401/429, plus Tavily's 432 plan-limit and 433 pay-as-you-go-limit codes).
|
|
7
8
|
*
|
|
8
9
|
* The tools are an OPTION for search, never a replacement: they are
|
|
@@ -40,6 +41,30 @@ const RETRYABLE_REASON = new Map([
|
|
|
40
41
|
[433, 'Tavily pay-as-you-go limit exceeded'],
|
|
41
42
|
])
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Research is asynchronous: `POST /research` queues a task and returns a
|
|
46
|
+
* `request_id`; `GET /research/{request_id}` reports progress, then the report.
|
|
47
|
+
* We poll that task in-process so one tool call can return the finished report,
|
|
48
|
+
* and fall back to the bare id when the caller's budget runs out.
|
|
49
|
+
* https://docs.tavily.com/documentation/api-reference/endpoint/research
|
|
50
|
+
*/
|
|
51
|
+
const RESEARCH_POLL_INTERVAL_MS = 5000
|
|
52
|
+
/**
|
|
53
|
+
* How long a single call waits before handing the id back. `mini` finishes
|
|
54
|
+
* narrow questions in seconds (observed live: ~13s); `pro` and `auto` run for
|
|
55
|
+
* minutes, so they get the longer default. The official MCP tool blocks up to
|
|
56
|
+
* 5 min for mini and 15 for pro/auto; `RESEARCH_MAX_WAIT_SECONDS` matches that
|
|
57
|
+
* upper bound, and the default stays short enough that an agent is not stuck.
|
|
58
|
+
*/
|
|
59
|
+
const RESEARCH_DEFAULT_WAIT_SECONDS = 300
|
|
60
|
+
const RESEARCH_MINI_WAIT_SECONDS = 120
|
|
61
|
+
const RESEARCH_MAX_WAIT_SECONDS = 900
|
|
62
|
+
/** Per-HTTP-request ceiling; the caller's overall wait is bounded separately. */
|
|
63
|
+
const RESEARCH_REQUEST_TIMEOUT_MS = 30000
|
|
64
|
+
|
|
65
|
+
/** Task statuses after which polling stops. */
|
|
66
|
+
const RESEARCH_TERMINAL_STATUS = new Set(['completed', 'failed'])
|
|
67
|
+
|
|
43
68
|
const MANAGER_STATE = 'tavily-manager.json'
|
|
44
69
|
|
|
45
70
|
/**
|
|
@@ -192,6 +217,26 @@ function combineSignals(signal, ms) {
|
|
|
192
217
|
}
|
|
193
218
|
}
|
|
194
219
|
|
|
220
|
+
/** Sleep, rejecting as soon as the caller's signal aborts. */
|
|
221
|
+
function sleepWithAbort(ms, signal) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
if (signal !== undefined && signal !== null && signal.aborted === true) {
|
|
224
|
+
reject(new Error('tavily_research: aborted while waiting for the research task'))
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
const onAbort = () => {
|
|
228
|
+
clearTimeout(timer)
|
|
229
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
230
|
+
reject(new Error('tavily_research: aborted while waiting for the research task'))
|
|
231
|
+
}
|
|
232
|
+
const timer = setTimeout(() => {
|
|
233
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
234
|
+
resolve()
|
|
235
|
+
}, ms)
|
|
236
|
+
if (signal !== undefined && signal !== null) signal.addEventListener('abort', onAbort, { once: true })
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
195
240
|
function buildBody(input) {
|
|
196
241
|
const body = {
|
|
197
242
|
query: input.query,
|
|
@@ -209,38 +254,58 @@ function buildBody(input) {
|
|
|
209
254
|
return body
|
|
210
255
|
}
|
|
211
256
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Call one Tavily endpoint; returns a normalized outcome.
|
|
259
|
+
*
|
|
260
|
+
* `request` selects the transport: `method`/`path` default to a POST to
|
|
261
|
+
* `/<operation>` (search, extract, map, crawl, research-create), and
|
|
262
|
+
* `notFound: 'outcome'` turns a 404 into `{ kind: 'not-found' }` instead of an
|
|
263
|
+
* error — `GET /research/{id}` answers 404 for a valid task id when the key
|
|
264
|
+
* asking is not the one that owns it, which the retrieve path must be able to
|
|
265
|
+
* step past.
|
|
266
|
+
*/
|
|
267
|
+
async function callOperation(operation, body, key, exec, timeoutMs, request = {}) {
|
|
268
|
+
const method = request.method ?? 'POST'
|
|
269
|
+
const path = request.path ?? operation
|
|
214
270
|
const combined = combineSignals(exec.signal, timeoutMs)
|
|
215
271
|
try {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
body
|
|
225
|
-
|
|
226
|
-
})
|
|
272
|
+
const headers = {
|
|
273
|
+
accept: 'application/json',
|
|
274
|
+
authorization: 'Bearer ' + key,
|
|
275
|
+
'user-agent': 'dsh-tool-tavily-search/0.2.0',
|
|
276
|
+
}
|
|
277
|
+
const init = { method, headers, signal: combined.signal }
|
|
278
|
+
if (method !== 'GET') {
|
|
279
|
+
headers['content-type'] = 'application/json'
|
|
280
|
+
init.body = JSON.stringify(body ?? {})
|
|
281
|
+
}
|
|
282
|
+
const response = await fetch(`${TAVILY_BASE_URL}/${path}`, init)
|
|
227
283
|
if (RETRYABLE_STATUS.has(response.status)) {
|
|
228
284
|
return { kind: 'retryable', statusCode: response.status }
|
|
229
285
|
}
|
|
286
|
+
if (request.notFound === 'outcome' && response.status === 404) {
|
|
287
|
+
return { kind: 'not-found', statusCode: 404 }
|
|
288
|
+
}
|
|
230
289
|
if (!response.ok) {
|
|
231
290
|
let message = 'HTTP ' + response.status
|
|
291
|
+
let errorCode
|
|
232
292
|
try {
|
|
233
293
|
const parsed = await response.json()
|
|
234
294
|
if (parsed !== null && typeof parsed === 'object') {
|
|
235
295
|
if (typeof parsed.error === 'string') message = parsed.error
|
|
296
|
+
else if (typeof parsed.detail === 'string') message = parsed.detail
|
|
236
297
|
else if (parsed.error !== null && typeof parsed.error === 'object' && typeof parsed.error.message === 'string') message = parsed.error.message
|
|
237
298
|
// 432/433 and other API failures arrive as {"detail":{"error":…}}.
|
|
238
299
|
else if (parsed.detail !== undefined && parsed.detail !== null && typeof parsed.detail === 'object' && typeof parsed.detail.error === 'string') message = parsed.detail.error
|
|
300
|
+
const detail = parsed.detail !== null && typeof parsed.detail === 'object' ? parsed.detail : parsed
|
|
301
|
+
if (typeof detail.error_code === 'string') errorCode = detail.error_code
|
|
239
302
|
}
|
|
240
303
|
} catch {
|
|
241
304
|
/* keep the status message */
|
|
242
305
|
}
|
|
243
|
-
|
|
306
|
+
const error = new Error(`tavily_${operation}: ${message}`)
|
|
307
|
+
if (errorCode !== undefined) error.errorCode = errorCode
|
|
308
|
+
throw error
|
|
244
309
|
}
|
|
245
310
|
return { kind: 'ok', statusCode: response.status, json: await response.json() }
|
|
246
311
|
} catch (error) {
|
|
@@ -487,6 +552,134 @@ function renderJson(_args, value) {
|
|
|
487
552
|
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
488
553
|
}
|
|
489
554
|
|
|
555
|
+
/* ---------------------------------------------------------------------------
|
|
556
|
+
* tavily_research / tavily_get_research. Research is the one Tavily capability
|
|
557
|
+
* the four direct tools cannot reach: `POST /research` queues an asynchronous
|
|
558
|
+
* task and `GET /research/{request_id}` returns a synthesized, cited report.
|
|
559
|
+
* It is deliberately NOT a search replacement either — it costs far more
|
|
560
|
+
* credits (mini 4-110, pro 15-250) than a `tavily_search` call.
|
|
561
|
+
* ------------------------------------------------------------------------- */
|
|
562
|
+
|
|
563
|
+
function normalizeOutputSchema(value) {
|
|
564
|
+
if (value === undefined || value === null) return undefined
|
|
565
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('output_schema must be a JSON Schema object')
|
|
566
|
+
const properties = value.properties
|
|
567
|
+
if (properties === null || properties === undefined || typeof properties !== 'object' || Array.isArray(properties) || Object.keys(properties).length === 0) {
|
|
568
|
+
throw new Error('output_schema must include a non-empty properties object')
|
|
569
|
+
}
|
|
570
|
+
if (value.required !== undefined && (!Array.isArray(value.required) || value.required.some((name) => typeof name !== 'string'))) {
|
|
571
|
+
throw new Error('output_schema.required must be an array of property names')
|
|
572
|
+
}
|
|
573
|
+
return value
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function domainList(args, name) {
|
|
577
|
+
const value = optionalStringList(args, name)
|
|
578
|
+
if (value === undefined) return []
|
|
579
|
+
if (value.length > 20) throw new Error(`${name} accepts at most 20 entries`)
|
|
580
|
+
return value
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function clampWaitSeconds(value, model) {
|
|
584
|
+
const fallback = model === 'mini' ? RESEARCH_MINI_WAIT_SECONDS : RESEARCH_DEFAULT_WAIT_SECONDS
|
|
585
|
+
if (value === undefined) return fallback
|
|
586
|
+
const n = Number(value)
|
|
587
|
+
if (!Number.isFinite(n) || n < 0) throw new Error('timeout_seconds must be a number of seconds, 0 or more')
|
|
588
|
+
return Math.min(RESEARCH_MAX_WAIT_SECONDS, Math.round(n))
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Normalize `tavily_research` arguments. The API field is `input`; `query` is
|
|
593
|
+
* accepted as an alias because every neighbouring tool spells it that way.
|
|
594
|
+
*/
|
|
595
|
+
export function normalizeResearchArgs(args) {
|
|
596
|
+
const raw = typeof args.input === 'string' && args.input.trim().length > 0
|
|
597
|
+
? args.input
|
|
598
|
+
: (typeof args.query === 'string' ? args.query : '')
|
|
599
|
+
const input = raw.trim()
|
|
600
|
+
if (input.length === 0) throw new Error('tavily_research: input must be a non-empty string')
|
|
601
|
+
const model = optionalEnum(args, 'model', ['mini', 'pro', 'auto']) ?? 'auto'
|
|
602
|
+
return {
|
|
603
|
+
input,
|
|
604
|
+
model,
|
|
605
|
+
citationFormat: optionalEnum(args, 'citation_format', ['numbered', 'mla', 'apa', 'chicago']),
|
|
606
|
+
outputLength: optionalEnum(args, 'output_length', ['short', 'standard', 'long']),
|
|
607
|
+
includeDomains: domainList(args, 'include_domains'),
|
|
608
|
+
excludeDomains: domainList(args, 'exclude_domains'),
|
|
609
|
+
outputSchema: normalizeOutputSchema(args.output_schema),
|
|
610
|
+
waitSeconds: clampWaitSeconds(args.timeout_seconds, model),
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function buildResearchBody(input) {
|
|
615
|
+
const body = { input: input.input, model: input.model }
|
|
616
|
+
include(body, 'citation_format', input.citationFormat)
|
|
617
|
+
include(body, 'output_length', input.outputLength)
|
|
618
|
+
if (input.includeDomains.length > 0) body.include_domains = input.includeDomains
|
|
619
|
+
if (input.excludeDomains.length > 0) body.exclude_domains = input.excludeDomains
|
|
620
|
+
include(body, 'output_schema', input.outputSchema)
|
|
621
|
+
return body
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Whether a research `status` ends the polling loop. */
|
|
625
|
+
export function isTerminalResearchStatus(status) {
|
|
626
|
+
return RESEARCH_TERMINAL_STATUS.has(status)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Project a research task response from any of the three calls that produce
|
|
631
|
+
* one (create, poll, retrieve). `content` is a string for a prose report and a
|
|
632
|
+
* real JSON object when `output_schema` was supplied, so an object is
|
|
633
|
+
* serialized and flagged rather than dropped.
|
|
634
|
+
*/
|
|
635
|
+
export function projectResearch(response) {
|
|
636
|
+
const status = typeof response?.status === 'string' && response.status.length > 0 ? response.status : 'unknown'
|
|
637
|
+
const out = {
|
|
638
|
+
requestId: typeof response?.request_id === 'string' ? response.request_id : '',
|
|
639
|
+
status,
|
|
640
|
+
}
|
|
641
|
+
if (typeof response?.created_at === 'string') out.createdAt = response.created_at
|
|
642
|
+
if (typeof response?.model === 'string') out.model = response.model
|
|
643
|
+
if (typeof response?.response_time === 'number') out.responseTime = response.response_time
|
|
644
|
+
if (status === 'completed') {
|
|
645
|
+
if (typeof response.content === 'string' && response.content.length > 0) out.content = response.content
|
|
646
|
+
else if (response.content !== null && typeof response.content === 'object') {
|
|
647
|
+
out.content = JSON.stringify(response.content, null, 2)
|
|
648
|
+
out.contentType = 'json'
|
|
649
|
+
}
|
|
650
|
+
const sources = Array.isArray(response.sources) ? response.sources.flatMap((source) => {
|
|
651
|
+
if (source === null || typeof source !== 'object' || typeof source.url !== 'string' || source.url.length === 0) return []
|
|
652
|
+
return [{
|
|
653
|
+
url: source.url,
|
|
654
|
+
...(typeof source.title === 'string' && source.title.length > 0 ? { title: source.title } : {}),
|
|
655
|
+
...(typeof source.favicon === 'string' && source.favicon.length > 0 ? { favicon: source.favicon } : {}),
|
|
656
|
+
}]
|
|
657
|
+
}) : []
|
|
658
|
+
if (sources.length > 0) out.sources = sources
|
|
659
|
+
}
|
|
660
|
+
if (response?.usage !== null && typeof response?.usage === 'object' && typeof response.usage.credits === 'number') {
|
|
661
|
+
out.credits = response.usage.credits
|
|
662
|
+
}
|
|
663
|
+
return out
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function renderResearch(_args, value) {
|
|
667
|
+
const heading = value.status === 'completed'
|
|
668
|
+
? 'Tavily research report'
|
|
669
|
+
: value.status === 'failed' ? 'Tavily research failed' : 'Tavily research ' + value.status
|
|
670
|
+
const parts = [heading + ' — request ' + value.requestId]
|
|
671
|
+
if (typeof value.content === 'string' && value.content.length > 0) parts.push(value.content)
|
|
672
|
+
if (Array.isArray(value.sources) && value.sources.length > 0) {
|
|
673
|
+
parts.push('Sources:\n' + value.sources.map((source) => {
|
|
674
|
+
const label = typeof source.title === 'string' && source.title.length > 0 ? source.title : hostnameOf(source.url)
|
|
675
|
+
return '- [' + label + '](' + source.url + ')'
|
|
676
|
+
}).join('\n'))
|
|
677
|
+
}
|
|
678
|
+
if (typeof value.note === 'string' && value.note.length > 0) parts.push(value.note)
|
|
679
|
+
if (typeof value.credits === 'number') parts.push('Credits used: ' + value.credits)
|
|
680
|
+
return [{ type: 'text', text: parts.join('\n\n') }]
|
|
681
|
+
}
|
|
682
|
+
|
|
490
683
|
const PAGE_SCHEMA = {
|
|
491
684
|
type: 'object',
|
|
492
685
|
additionalProperties: false,
|
|
@@ -532,6 +725,33 @@ const CRAWL_OUTPUT_SCHEMA = {
|
|
|
532
725
|
},
|
|
533
726
|
}
|
|
534
727
|
|
|
728
|
+
const RESEARCH_SOURCE_SCHEMA = {
|
|
729
|
+
type: 'object',
|
|
730
|
+
additionalProperties: false,
|
|
731
|
+
properties: {
|
|
732
|
+
url: { type: 'string', required: true },
|
|
733
|
+
title: { type: 'string' },
|
|
734
|
+
favicon: { type: 'string' },
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const RESEARCH_OUTPUT_SCHEMA = {
|
|
739
|
+
type: 'object',
|
|
740
|
+
additionalProperties: false,
|
|
741
|
+
properties: {
|
|
742
|
+
requestId: { type: 'string', required: true },
|
|
743
|
+
status: { type: 'string', required: true },
|
|
744
|
+
content: { type: 'string' },
|
|
745
|
+
contentType: { type: 'string' },
|
|
746
|
+
createdAt: { type: 'string' },
|
|
747
|
+
model: { type: 'string' },
|
|
748
|
+
sources: { type: 'array', items: RESEARCH_SOURCE_SCHEMA },
|
|
749
|
+
credits: { type: 'number' },
|
|
750
|
+
responseTime: { type: 'number' },
|
|
751
|
+
note: { type: 'string' },
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
|
|
535
755
|
const NAVIGATION_PARAMETERS = {
|
|
536
756
|
url: { type: 'string', required: true, description: 'The HTTP(S) URL to map or crawl.' },
|
|
537
757
|
instructions: { type: 'string', description: 'Natural-language guidance for which pages to include.' },
|
|
@@ -544,16 +764,19 @@ const NAVIGATION_PARAMETERS = {
|
|
|
544
764
|
}
|
|
545
765
|
|
|
546
766
|
/**
|
|
547
|
-
* Register the `tavily_search`, `tavily_extract`, `tavily_map`,
|
|
548
|
-
* `tavily_crawl
|
|
549
|
-
* unregisters all of them plus the
|
|
767
|
+
* Register the `tavily_search`, `tavily_extract`, `tavily_map`,
|
|
768
|
+
* `tavily_crawl`, `tavily_research`, and `tavily_get_research` model tools on
|
|
769
|
+
* `ctx`, and return one disposer that unregisters all of them plus the three
|
|
770
|
+
* prompt sections.
|
|
550
771
|
*
|
|
551
772
|
* No web search provider is registered here: `web_search` keeps its native
|
|
552
773
|
* provider and `ctx.web` is never touched.
|
|
553
774
|
*/
|
|
554
|
-
export function installTavilyTool(ctx) {
|
|
775
|
+
export function installTavilyTool(ctx, options = {}) {
|
|
555
776
|
let rotation = 0
|
|
556
777
|
const scheduler = createAccountScheduler()
|
|
778
|
+
// Injectable so tests can drive the poll loop without waiting seconds.
|
|
779
|
+
const pollIntervalMs = options.pollIntervalMs ?? RESEARCH_POLL_INTERVAL_MS
|
|
557
780
|
|
|
558
781
|
// Every registration is collected into one disposer so the fiber tears the
|
|
559
782
|
// half down cleanly: on row unload, and on the Plugins page's runtime unload
|
|
@@ -579,8 +802,31 @@ export function installTavilyTool(ctx) {
|
|
|
579
802
|
return { keys: [], source: 'unconfigured' }
|
|
580
803
|
}
|
|
581
804
|
|
|
805
|
+
/** Resolve the key pool and the concurrency policy for one call. */
|
|
806
|
+
async function resolvePool(operation, exec) {
|
|
807
|
+
const { keys, source } = await resolveKeys()
|
|
808
|
+
if (keys.length === 0) {
|
|
809
|
+
throw new Error(`tavily_${operation}: TAVILY_API_KEYS is not configured — add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml (comma-separated)`)
|
|
810
|
+
}
|
|
811
|
+
const policy = readConcurrencyPolicy()
|
|
812
|
+
const groups = policy.balanced ? keys.map((key) => accountOfKey(key, policy.accounts)) : null
|
|
813
|
+
const signal = exec !== null && exec !== undefined ? exec.signal : undefined
|
|
814
|
+
return { keys, source, policy, groups, signal }
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Run one request on `pool.keys[index]`, honouring the account cap. */
|
|
818
|
+
async function runOnPoolKey(pool, index, operation, body, timeoutMs, exec, request) {
|
|
819
|
+
const group = pool.groups === null ? null : pool.groups[index]
|
|
820
|
+
if (group !== null) await scheduler.acquire(group, pool.policy.cap, pool.signal)
|
|
821
|
+
try {
|
|
822
|
+
return await callOperation(operation, body, pool.keys[index], exec, timeoutMs, request)
|
|
823
|
+
} finally {
|
|
824
|
+
if (group !== null) scheduler.release(group)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
582
828
|
/**
|
|
583
|
-
* Run `body` through the rotation/failover loop
|
|
829
|
+
* Run `body` through the rotation/failover loop.
|
|
584
830
|
*
|
|
585
831
|
* The first key is picked from the shared round-robin cursor, and each
|
|
586
832
|
* attempt advances by exactly one slot, so one call tries every configured
|
|
@@ -593,17 +839,15 @@ export function installTavilyTool(ctx) {
|
|
|
593
839
|
* calls spread across accounts, and a call queues rather than opening a
|
|
594
840
|
* second connection to one free account. Every other strategy keeps the
|
|
595
841
|
* plain order above, untouched.
|
|
842
|
+
*
|
|
843
|
+
* Returns the response JSON plus the pool slot that served it, because
|
|
844
|
+
* research must keep asking with the key that owns the task.
|
|
596
845
|
*/
|
|
597
|
-
async function
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
throw new Error(`tavily_${operation}: TAVILY_API_KEYS is not configured — add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml (comma-separated)`)
|
|
601
|
-
}
|
|
846
|
+
async function requestWithRotationResult(operation, body, timeoutMs, exec, detail, request) {
|
|
847
|
+
const pool = await resolvePool(operation, exec)
|
|
848
|
+
const { keys, source, policy, groups, signal } = pool
|
|
602
849
|
const attempts = keys.length
|
|
603
850
|
const first = rotation
|
|
604
|
-
const policy = readConcurrencyPolicy()
|
|
605
|
-
const groups = policy.balanced ? keys.map((key) => accountOfKey(key, policy.accounts)) : null
|
|
606
|
-
const signal = exec !== null && exec !== undefined ? exec.signal : undefined
|
|
607
851
|
const tried = new Set()
|
|
608
852
|
const pickBalancedIndex = (fallbackAttempt) => {
|
|
609
853
|
let leastBusy = null
|
|
@@ -635,7 +879,7 @@ export function installTavilyTool(ctx) {
|
|
|
635
879
|
const started = Date.now()
|
|
636
880
|
let outcome
|
|
637
881
|
try {
|
|
638
|
-
outcome = await callOperation(operation, body, key, exec, timeoutMs)
|
|
882
|
+
outcome = await callOperation(operation, body, key, exec, timeoutMs, request)
|
|
639
883
|
} finally {
|
|
640
884
|
if (group !== null) scheduler.release(group)
|
|
641
885
|
}
|
|
@@ -643,7 +887,7 @@ export function installTavilyTool(ctx) {
|
|
|
643
887
|
rotation = (index + 1) % keys.length
|
|
644
888
|
const extra = typeof detail === 'function' ? detail(outcome.json) : {}
|
|
645
889
|
ctx.logger.info(`tavily_${operation}: ok %j`, { ...extra, status: outcome.statusCode, ms: Date.now() - started, keys: keys.length, source })
|
|
646
|
-
return outcome.json
|
|
890
|
+
return { json: outcome.json, index, keys, pool }
|
|
647
891
|
}
|
|
648
892
|
lastRetryable = outcome.statusCode
|
|
649
893
|
rotation = (index + 1) % keys.length
|
|
@@ -653,6 +897,11 @@ export function installTavilyTool(ctx) {
|
|
|
653
897
|
throw new Error(`tavily_${operation}: all ${attempts} configured key(s) failed with HTTP ${lastRetryable} (${reason})`)
|
|
654
898
|
}
|
|
655
899
|
|
|
900
|
+
/** The rotation loop's JSON, for callers that do not need the owning key. */
|
|
901
|
+
async function requestWithRotation(operation, body, timeoutMs, exec, detail) {
|
|
902
|
+
return (await requestWithRotationResult(operation, body, timeoutMs, exec, detail)).json
|
|
903
|
+
}
|
|
904
|
+
|
|
656
905
|
tools.register(defineTool({
|
|
657
906
|
name: 'tavily_search',
|
|
658
907
|
description: 'Search the web through the Tavily API. Returns ranked results with titles, URLs, snippets, relevance scores, and an optional generated answer. Supports basic/advanced depth, news topic with a freshness window, and domain allow/deny filters.',
|
|
@@ -757,6 +1006,117 @@ export function installTavilyTool(ctx) {
|
|
|
757
1006
|
},
|
|
758
1007
|
}))
|
|
759
1008
|
|
|
1009
|
+
/** `GET /research/{id}` — the task is owned by one key, so reuse that slot. */
|
|
1010
|
+
const researchPath = (requestId) => 'research/' + encodeURIComponent(requestId) + '?include_usage=true'
|
|
1011
|
+
|
|
1012
|
+
tools.register(defineTool({
|
|
1013
|
+
name: 'tavily_research',
|
|
1014
|
+
description: 'Run multi-source research through the Tavily Research API and return a synthesized, cited report. It starts an asynchronous research task and waits for it, so one call can return the finished report; if the wait runs out it returns the request id for tavily_get_research. Costs far more credits than tavily_search (mini 4-110, pro 15-250): use it when a written report is wanted, not for an ordinary lookup.',
|
|
1015
|
+
parameters: {
|
|
1016
|
+
input: { type: 'string', required: true, description: 'The research task or question to investigate, in full. `query` is accepted as an alias.' },
|
|
1017
|
+
model: { type: 'string', enum: ['mini', 'pro', 'auto'], description: 'mini for narrow, well-scoped questions; pro for broad multi-angle research; auto (default) lets Tavily choose.' },
|
|
1018
|
+
citation_format: { type: 'string', enum: ['numbered', 'mla', 'apa', 'chicago'], description: 'Citation style inside the report (default numbered).' },
|
|
1019
|
+
output_length: { type: 'string', enum: ['short', 'standard', 'long'], description: 'Typed control over report size; a target, not a hard cap (default standard).' },
|
|
1020
|
+
include_domains: { type: 'array', items: { type: 'string' }, description: 'Soft preference for sources, host-based; at most 20 domains.' },
|
|
1021
|
+
exclude_domains: { type: 'array', items: { type: 'string' }, description: 'Hard blocklist, subdomains included; at most 20 domains.' },
|
|
1022
|
+
output_schema: { type: 'object', additionalProperties: true, description: 'Optional JSON Schema object describing a structured report; must include a non-empty properties object.' },
|
|
1023
|
+
timeout_seconds: { type: 'number', description: 'How long to wait for completion before returning the still-running task id: 0 returns immediately after queueing; defaults to 120s for mini and 300s otherwise; maximum 900.' },
|
|
1024
|
+
},
|
|
1025
|
+
output: { schema: RESEARCH_OUTPUT_SCHEMA, render: renderResearch },
|
|
1026
|
+
timeoutMs: (RESEARCH_MAX_WAIT_SECONDS + 60) * 1000,
|
|
1027
|
+
isConcurrencySafe: () => true,
|
|
1028
|
+
async execute(args, exec) {
|
|
1029
|
+
const input = normalizeResearchArgs(args)
|
|
1030
|
+
let started
|
|
1031
|
+
try {
|
|
1032
|
+
started = await requestWithRotationResult('research', buildResearchBody(input), RESEARCH_REQUEST_TIMEOUT_MS, exec, (json) => ({
|
|
1033
|
+
model: json.model,
|
|
1034
|
+
status: json.status,
|
|
1035
|
+
}))
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
if (error instanceof Error && error.errorCode === 'research_stream_required') {
|
|
1038
|
+
throw new Error('tavily_research: this Tavily key requires streamed research, which this plugin does not support — use the Tavily MCP row (tavily_research) for this account')
|
|
1039
|
+
}
|
|
1040
|
+
throw error
|
|
1041
|
+
}
|
|
1042
|
+
const requestId = typeof started.json?.request_id === 'string' ? started.json.request_id : ''
|
|
1043
|
+
if (requestId.length === 0) throw new Error('tavily_research: the API returned no request_id')
|
|
1044
|
+
|
|
1045
|
+
let current = started.json
|
|
1046
|
+
let stopped = ''
|
|
1047
|
+
const deadline = Date.now() + input.waitSeconds * 1000
|
|
1048
|
+
const signal = exec !== null && exec !== undefined ? exec.signal : undefined
|
|
1049
|
+
while (!isTerminalResearchStatus(typeof current?.status === 'string' ? current.status : '') && Date.now() < deadline) {
|
|
1050
|
+
await sleepWithAbort(Math.min(pollIntervalMs, deadline - Date.now()), signal)
|
|
1051
|
+
const outcome = await runOnPoolKey(started.pool, started.index, 'research', undefined, RESEARCH_REQUEST_TIMEOUT_MS, exec, {
|
|
1052
|
+
method: 'GET',
|
|
1053
|
+
path: researchPath(requestId),
|
|
1054
|
+
notFound: 'outcome',
|
|
1055
|
+
})
|
|
1056
|
+
if (outcome.kind === 'ok') {
|
|
1057
|
+
current = outcome.json
|
|
1058
|
+
continue
|
|
1059
|
+
}
|
|
1060
|
+
if (outcome.kind === 'not-found') {
|
|
1061
|
+
stopped = 'Tavily no longer knows this request id (HTTP 404).'
|
|
1062
|
+
break
|
|
1063
|
+
}
|
|
1064
|
+
stopped = `Polling stopped after HTTP ${outcome.statusCode}.`
|
|
1065
|
+
break
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const value = projectResearch(current)
|
|
1069
|
+
if (value.requestId.length === 0) value.requestId = requestId
|
|
1070
|
+
if (!isTerminalResearchStatus(value.status)) {
|
|
1071
|
+
const reason = stopped.length > 0
|
|
1072
|
+
? stopped
|
|
1073
|
+
: input.waitSeconds === 0
|
|
1074
|
+
? 'Queued without waiting.'
|
|
1075
|
+
: `Still running after ${input.waitSeconds}s.`
|
|
1076
|
+
value.note = reason + ' Call tavily_get_research with requestId ' + requestId + ' to collect the report when it finishes.'
|
|
1077
|
+
}
|
|
1078
|
+
return value
|
|
1079
|
+
},
|
|
1080
|
+
}))
|
|
1081
|
+
|
|
1082
|
+
tools.register(defineTool({
|
|
1083
|
+
name: 'tavily_get_research',
|
|
1084
|
+
description: 'Retrieve a Tavily research task by request id: its status while it runs, and the full cited report once it has completed. Use it after tavily_research returns a task that is still running.',
|
|
1085
|
+
parameters: {
|
|
1086
|
+
request_id: { type: 'string', required: true, description: 'The requestId returned by tavily_research.' },
|
|
1087
|
+
},
|
|
1088
|
+
output: { schema: RESEARCH_OUTPUT_SCHEMA, render: renderResearch },
|
|
1089
|
+
timeoutMs: 60000,
|
|
1090
|
+
isConcurrencySafe: () => true,
|
|
1091
|
+
async execute(args, exec) {
|
|
1092
|
+
const requestId = optionalString(args, 'request_id')
|
|
1093
|
+
if (requestId === undefined) throw new Error('tavily_get_research: request_id must be a non-empty string')
|
|
1094
|
+
const pool = await resolvePool('research', exec)
|
|
1095
|
+
const first = rotation
|
|
1096
|
+
let lastRetryable = 0
|
|
1097
|
+
// A task is owned by the key that created it, and a pool may span
|
|
1098
|
+
// accounts, so a 404 means "not this key" until every key has answered.
|
|
1099
|
+
for (let attempt = 0; attempt < pool.keys.length; attempt++) {
|
|
1100
|
+
const index = (first + attempt) % pool.keys.length
|
|
1101
|
+
const outcome = await runOnPoolKey(pool, index, 'research', undefined, RESEARCH_REQUEST_TIMEOUT_MS, exec, {
|
|
1102
|
+
method: 'GET',
|
|
1103
|
+
path: researchPath(requestId),
|
|
1104
|
+
notFound: 'outcome',
|
|
1105
|
+
})
|
|
1106
|
+
if (outcome.kind === 'ok') {
|
|
1107
|
+
rotation = (index + 1) % pool.keys.length
|
|
1108
|
+
return projectResearch(outcome.json)
|
|
1109
|
+
}
|
|
1110
|
+
if (outcome.kind === 'retryable') lastRetryable = outcome.statusCode
|
|
1111
|
+
}
|
|
1112
|
+
if (lastRetryable !== 0) {
|
|
1113
|
+
const reason = RETRYABLE_REASON.get(lastRetryable) ?? 'invalid key or rate limit'
|
|
1114
|
+
throw new Error(`tavily_research: all ${pool.keys.length} configured key(s) failed with HTTP ${lastRetryable} (${reason})`)
|
|
1115
|
+
}
|
|
1116
|
+
throw new Error(`tavily_research: no configured key knows request ${requestId} (HTTP 404) — check the id, or the key that started the task may have been removed`)
|
|
1117
|
+
},
|
|
1118
|
+
}))
|
|
1119
|
+
|
|
760
1120
|
const systemPrompt = ctx.get('systemPrompt')
|
|
761
1121
|
const section = (value) => {
|
|
762
1122
|
if (systemPrompt !== undefined) disposers.push(systemPrompt.section(value))
|
|
@@ -772,6 +1132,11 @@ export function installTavilyTool(ctx) {
|
|
|
772
1132
|
order: 112,
|
|
773
1133
|
text: 'For pages already known by URL, use tavily_extract to pull their full content, tavily_map to discover a site\'s links without fetching content, and tavily_crawl to capture the extracted content of an entire site. These are optional extras on top of the built-in web_search tool, which is never replaced.',
|
|
774
1134
|
})
|
|
1135
|
+
section({
|
|
1136
|
+
name: 'tool:tavily_research',
|
|
1137
|
+
order: 113,
|
|
1138
|
+
text: 'For a question that wants a synthesized, multi-source written report rather than a list of links, use tavily_research. It queues a Tavily research task and waits for the finished report. It costs far more credits than tavily_search (mini 4-110, pro 15-250), so prefer tavily_search for ordinary lookups and reserve research for broad or multi-angle questions. If it returns status pending or in_progress the task is still running: call tavily_get_research with the returned requestId to collect the report later.',
|
|
1139
|
+
})
|
|
775
1140
|
}
|
|
776
1141
|
|
|
777
1142
|
const credentials = ctx.get('credentials')
|
package/src/invariant.js
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Package-owned invariant companion for `@moguiyu/dsh-tavily`.
|
|
3
|
-
*/
|
|
4
|
-
const PACKAGE_NAME = '@moguiyu/dsh-tavily'
|
|
5
|
-
|
|
6
|
-
/** Cordis companion plugin name. */
|
|
7
|
-
const name = 'dsh-tavily-invariant'
|
|
8
|
-
|
|
9
|
-
/** Service required before the companion can reserve package ownership. */
|
|
10
|
-
const inject = ['invariants']
|
|
11
|
-
|
|
12
|
-
/** No runtime invariant: this tool has no independent lifecycle stream. */
|
|
13
|
-
const install = () => {}
|
|
14
|
-
|
|
15
|
-
/** Register this package's invariant companion. */
|
|
16
|
-
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
|
17
|
-
|
|
18
|
-
export { apply, inject, name }
|