@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 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 routes, and the browser card on the **Plugins**
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",
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 轮换、故障转移与按账号并发上限,插件配置页含密钥与用量管理卡片;内置 web_search 永不替换。",
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
- "lib",
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
- * plus the extra Tavily direct tools (`tavily_extract`, `tavily_map`,
4
- * `tavily_crawl`). Keys resolve from the `TAVILY_API_KEYS` credential per
5
- * call; rotation is round-robin with failover on every per-key refusal
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
- /** POST to `https://api.tavily.com/<operation>`; returns a normalized outcome. */
213
- async function callOperation(operation, body, key, exec, timeoutMs) {
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 response = await fetch(`${TAVILY_BASE_URL}/${operation}`, {
217
- method: 'POST',
218
- headers: {
219
- 'content-type': 'application/json',
220
- accept: 'application/json',
221
- authorization: 'Bearer ' + key,
222
- 'user-agent': 'dsh-tool-tavily-search/0.2.0',
223
- },
224
- body: JSON.stringify(body),
225
- signal: combined.signal,
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
- throw new Error(`tavily_${operation}: ${message}`)
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`, and
548
- * `tavily_crawl` model tools on `ctx`, and return one disposer that
549
- * unregisters all of them plus the two prompt sections.
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; returns the response JSON.
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 requestWithRotation(operation, body, timeoutMs, exec, detail) {
598
- const { keys, source } = await resolveKeys()
599
- if (keys.length === 0) {
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 }