@7n/llm-lib 2.2.1 → 2.3.0
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/CHANGELOG.md +6 -0
- package/lib/agent-fix.mjs +13 -1
- package/lib/docs/agent-fix.md +3 -1
- package/lib/docs/web-tools.md +37 -0
- package/lib/web-tools.mjs +321 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.3.0] - 2026-07-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- web-tools (Фаза A3): web_search/web_fetch для cloud-профілів — SSRF-guard (кожен redirect-hop), мінімальна html→text екстракція без нових залежностей, один search-провайдер за ключем (Brave/Tavily/Exa, N_LLM_SEARCH_PROVIDER); opts.webTools у runAgentFix (дефолт off)
|
|
8
|
+
|
|
3
9
|
## [2.2.1] - 2026-07-11
|
|
4
10
|
|
|
5
11
|
### Changed
|
package/lib/agent-fix.mjs
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { env } from 'node:process'
|
|
34
34
|
import { homedir } from 'node:os'
|
|
35
35
|
import { createAnchoredTools } from './anchored-edit.mjs'
|
|
36
|
+
import { createWebTools } from './web-tools.mjs'
|
|
36
37
|
import { getRegistry, resolveModelSpec } from './internal/registry.mjs'
|
|
37
38
|
import { isLocalModel, thinkingLevelForTier } from './model-tiers.mjs'
|
|
38
39
|
import { createWriteGuard, gitRoot } from './write-guard.mjs'
|
|
@@ -204,7 +205,8 @@ async function defaultCreateSession({
|
|
|
204
205
|
astContext,
|
|
205
206
|
selfCheck,
|
|
206
207
|
chain,
|
|
207
|
-
anchoredEdits = false
|
|
208
|
+
anchoredEdits = false,
|
|
209
|
+
webTools = false
|
|
208
210
|
}) {
|
|
209
211
|
const { createAgentSession, SessionManager, DefaultResourceLoader, SettingsManager, defineTool } =
|
|
210
212
|
await import('@earendil-works/pi-coding-agent')
|
|
@@ -253,6 +255,12 @@ async function defaultCreateSession({
|
|
|
253
255
|
tools = ['grep', 'find', 'write', 'ls', 'ast_facts', 'self_check', 'read_anchored', 'edit_anchored']
|
|
254
256
|
customTools.push(readTool, editTool)
|
|
255
257
|
}
|
|
258
|
+
// A3: web-доступ — лише за явним профілем (cloud-тири за дизайном); read-only tools.
|
|
259
|
+
if (webTools) {
|
|
260
|
+
const { searchTool, fetchTool } = createWebTools({ defineTool })
|
|
261
|
+
tools = [...tools, 'web_search', 'web_fetch']
|
|
262
|
+
customTools.push(searchTool, fetchTool)
|
|
263
|
+
}
|
|
256
264
|
|
|
257
265
|
const { session } = await createAgentSession({
|
|
258
266
|
modelRegistry: registry,
|
|
@@ -279,6 +287,7 @@ async function defaultCreateSession({
|
|
|
279
287
|
* verify?: (args: { touchedFiles: string[] }) => Promise<{ ok: boolean, output?: string }> | { ok: boolean, output?: string },
|
|
280
288
|
* verifyMax?: number,
|
|
281
289
|
* anchoredEdits?: boolean,
|
|
290
|
+
* webTools?: boolean,
|
|
282
291
|
* deps?: { createSession?: (args: object) => Promise<object>, getRegistry?: () => Promise<object>,
|
|
283
292
|
* registry?: object, root?: string|null,
|
|
284
293
|
* astContext?: (path: string) => object,
|
|
@@ -300,6 +309,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
|
|
|
300
309
|
verify = null,
|
|
301
310
|
verifyMax = VERIFY_MAX_DEFAULT,
|
|
302
311
|
anchoredEdits = false,
|
|
312
|
+
webTools = false,
|
|
303
313
|
deps = {}
|
|
304
314
|
} = opts
|
|
305
315
|
const createSession = deps.createSession ?? defaultCreateSession
|
|
@@ -359,6 +369,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
|
|
|
359
369
|
astContext,
|
|
360
370
|
selfCheck,
|
|
361
371
|
anchoredEdits,
|
|
372
|
+
webTools,
|
|
362
373
|
// Заголовки кореляції — лише локальним моделям (myllm стоїть тільки перед ними).
|
|
363
374
|
chain: modelSpec && isLocalModel(modelSpec) ? chain : null
|
|
364
375
|
})
|
|
@@ -471,6 +482,7 @@ export async function runAgentFix(ruleId, violation, cwd, opts = {}) {
|
|
|
471
482
|
verifyAttempts: verifyAttempts.length,
|
|
472
483
|
verifyOk: verifyAttempts.length > 0 ? verifyAttempts.at(-1).ok : null,
|
|
473
484
|
anchoredEdits,
|
|
485
|
+
webTools,
|
|
474
486
|
wallMs: clock() - startedAt,
|
|
475
487
|
error,
|
|
476
488
|
...chain?.traceFields()
|
package/lib/docs/agent-fix.md
CHANGED
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: agent-fix.mjs
|
|
4
4
|
resource: llm-lib/lib/agent-fix.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 83a189d4
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -18,6 +18,8 @@ buildFixPrompt готує текстовий промпт, що містить
|
|
|
18
18
|
runAgentFix виконує повний агентний цикл для спроби виправлення порушення правила, включаючи взаємодію з інструментами, застосування патча та фіксацію телеметрії.
|
|
19
19
|
Виклик сесії огорнутий timeout-гонкою: `opts.timeoutMs` (дефолт 300s, коли consumer не передав значення) на спрацюванні abort-ить сесію і повертає помилку `fix timeout …` — зависла LLM-сесія (напр. мертва SSE) не блокує виклик назавжди.
|
|
20
20
|
|
|
21
|
+
Web-профіль (опційний `opts.webTools`, Фаза A3): додає read-only tools `web_search`/`web_fetch` (див. web-tools.md; SSRF-guard, ліміти розміру) — для правил із зовнішнім знанням на cloud-тирах; прапорець у трейсі, дефолт вимкнено.
|
|
22
|
+
|
|
21
23
|
Anchored-профіль (опційний `opts.anchoredEdits`, Фаза A2): toolset сесії заміняє built-in `read`/`edit` на строгі `read_anchored`/`edit_anchored` (див. anchored-edit.md; `write` лишається для нових файлів, під тим самим write-guard), а промпт отримує інструкцію anchored-циклу. Прапорець фіксується у трейсі (`anchoredEdits`) для A/B-аналізу; дефолт вимкнено.
|
|
22
24
|
|
|
23
25
|
Evidence-гейт (опційний `opts.verify`, Фаза A1 run-harness): після prompt-у модуль сам запускає canonical-перевірку consumer-а; провал інʼєктиться фідбеком у ту саму сесію — до `opts.verifyMax` додаткових ітерацій (дефолт 2), у межах того самого `timeoutMs` (залишок бюджету < 5s — чесна зупинка без ітерації). Гейт структурний: заяви агента про успіх не важать, джерелом правди лишається зовнішня перевірка. Помилка самої перевірки — інфраструктурна: ітерації не витрачаються, повертається `error` з префіксом `verify:`. Без `verify` — поведінка попередня (один прохід). Спроби фіксуються у `telemetry.verifyAttempts` і у trace (`verifyAttempts`, `verifyOk`).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: web-tools.mjs
|
|
4
|
+
resource: llm-lib/lib/web-tools.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 4242c966
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Огляд
|
|
10
|
+
|
|
11
|
+
Web-доступ для cloud-профілів run-harness (Фаза A3): пара pi-tools `web_search`/`web_fetch` — мінімальне ядро без нових залежностей (референс — pi-web-access, без його fallback-ланцюгів провайдерів і browser-режимів). Вмикається лише явним профілем consumer-а (agent-fix `opts.webTools`, за дизайном — cloud-тири); дефолт вимкнено.
|
|
12
|
+
|
|
13
|
+
## Поведінка
|
|
14
|
+
|
|
15
|
+
`web_fetch` ходить лише на публічні http(s)-адреси: SSRF-guard блокує інші схеми, `localhost`/`*.local`/`*.internal` і літеральні приватні IP (v4-діапазони, v6 loopback/link-local/ULA); redirect-и проходяться вручну (до 3 hop-ів) з guard-перевіркою кожного hop-а. HTML зводиться до тексту власним мінімальним стрипером (script/style/noscript вирізаються ітеративно без regex-backtracking, блокові теги → переноси, entity декодуються); json/plain віддаються як є. Відповідь обрізається лімітом (`maxChars`, дефолт 20k символів) з чесним прапорцем `truncated`; таймаут запиту 20s.
|
|
16
|
+
|
|
17
|
+
`web_search` працює через ОДНОГО провайдера: явний `N_LLM_SEARCH_PROVIDER` або перший наявний ключ (`BRAVE_API_KEY` → `TAVILY_API_KEY` → `EXA_API_KEY`); результати нормалізуються до `{title, url, snippet}`. Без жодного ключа tool чесно повертає структуровану відмову з інструкцією конфігурації — не виняток.
|
|
18
|
+
|
|
19
|
+
Вміст сторінок повертається tool-result-ом (дані, не інструкції) — prompt-injection зі сторінок не отримує системного рівня; помилки обох tools — структурований JSON-текст із причиною.
|
|
20
|
+
|
|
21
|
+
## Публічний API
|
|
22
|
+
|
|
23
|
+
assertPublicHttpUrl — SSRF-guard: розбирає URL або кидає Error з причиною відмови.
|
|
24
|
+
htmlToText — мінімальна html→text екстракція (без DOM-залежностей).
|
|
25
|
+
fetchPage — fetch з guard-ом на кожному redirect-hop-і, таймаутом і лімітом розміру.
|
|
26
|
+
resolveSearchProvider — вибір search-провайдера за env.
|
|
27
|
+
createWebTools — фабрика tool-дефініцій `web_search`/`web_fetch` (defineTool і fetch інжектяться — модуль pi-free).
|
|
28
|
+
|
|
29
|
+
## Де використовується
|
|
30
|
+
|
|
31
|
+
`agent-fix.mjs`: `opts.webTools: true` додає обидва tools у сесію (перші споживачі — правила з зовнішнім знанням: pin-перевірки ga, taze-подібні). Прапорець фіксується у trace для аналізу.
|
|
32
|
+
|
|
33
|
+
## Гарантії поведінки
|
|
34
|
+
|
|
35
|
+
- Жодного мережевого виклику без явного tool-виклику агента; лише http/https на публічні адреси.
|
|
36
|
+
- Відповіді обмежені за розміром; guard-відмови детерміновані й пояснені.
|
|
37
|
+
- Модуль pi-free; усі зовнішні ефекти (fetch, env) інжектовані — тести без мережі.
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/** @see ./docs/web-tools.md */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Web-доступ для cloud-профілів run-harness (Фаза A3, дизайн 2026-07-11).
|
|
5
|
+
*
|
|
6
|
+
* Мінімальне ядро без нових залежностей (референс — pi-web-access, без його
|
|
7
|
+
* fallback-ланцюгів провайдерів, browser-cookie режимів і video-екстракції):
|
|
8
|
+
* - `web_fetch` — global fetch → текстова екстракція (html→text власним
|
|
9
|
+
* мінімальним стрипером, json/plain — як є) з лімітом розміру.
|
|
10
|
+
* - `web_search` — ОДИН провайдер за конфігом: `N_LLM_SEARCH_PROVIDER` або
|
|
11
|
+
* перший наявний ключ (`BRAVE_API_KEY` → `TAVILY_API_KEY` → `EXA_API_KEY`).
|
|
12
|
+
*
|
|
13
|
+
* Безпека (нова поверхня довіри — URL формує модель):
|
|
14
|
+
* - SSRF-guard: лише http/https; блок localhost/*.local та літеральних IP
|
|
15
|
+
* приватних діапазонів; redirect-и проходяться вручну (до 3 hop-ів) із
|
|
16
|
+
* guard-перевіркою КОЖНОГО hop-а.
|
|
17
|
+
* - Вміст сторінок повертається як tool-result (дані, не інструкції) —
|
|
18
|
+
* prompt-injection зі сторінок не отримує системного рівня.
|
|
19
|
+
* - Ліміт відповіді (`maxChars`) — проти роздування контексту.
|
|
20
|
+
*
|
|
21
|
+
* Модуль pi-free: `defineTool` і fetch інжектяться; ввімкнення — профілем
|
|
22
|
+
* consumer-а (agent-fix `opts.webTools`, лише cloud-тири за дизайном).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { env as processEnv } from 'node:process'
|
|
26
|
+
|
|
27
|
+
/** Дефолтний таймаут одного web-запиту. */
|
|
28
|
+
const FETCH_TIMEOUT_MS = 20_000
|
|
29
|
+
/** Дефолтний ліміт символів відповіді tool-а (проти роздування контексту). */
|
|
30
|
+
const DEFAULT_MAX_CHARS = 20_000
|
|
31
|
+
/** Максимум redirect-hop-ів при ручному проході. */
|
|
32
|
+
const MAX_REDIRECTS = 3
|
|
33
|
+
/** Кількість результатів пошуку за замовчуванням. */
|
|
34
|
+
const DEFAULT_SEARCH_COUNT = 5
|
|
35
|
+
|
|
36
|
+
const PRIVATE_V4 = /^(?:0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/
|
|
37
|
+
const PRIVATE_V6 = /^(?:::1|::|fe80:|f[cd][0-9a-f]{2}:)/i
|
|
38
|
+
const HTML_CONTENT_TYPE = /text\/html|application\/xhtml/i
|
|
39
|
+
const BLOCK_CLOSE_TAGS = /<\/(?:p|div|li|h[1-6]|tr|section|article|blockquote|pre)>/gi
|
|
40
|
+
const BR_HR_TAGS = /<(?:br|hr)\s*\/?>/gi
|
|
41
|
+
const SPACES = /[ \t]+/g
|
|
42
|
+
const BLANK_LINES = /\n{3,}/g
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* SSRF-guard: чи можна ходити на URL. Кидає Error з причиною при відмові.
|
|
46
|
+
* Блокує не-http(s), localhost/*.local/*.internal і літеральні приватні IP
|
|
47
|
+
* (v4-діапазони, v6 loopback/link-local/ULA). DNS-резолюція не робиться —
|
|
48
|
+
* захист від літералів; довірений периметр consumer-а лишається його політикою.
|
|
49
|
+
* @param {string} rawUrl URL з tool-input
|
|
50
|
+
* @returns {URL} розібраний URL (для подальшого fetch)
|
|
51
|
+
*/
|
|
52
|
+
export function assertPublicHttpUrl(rawUrl) {
|
|
53
|
+
let url
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(rawUrl)
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(`невалідний URL: ${rawUrl}`)
|
|
58
|
+
}
|
|
59
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
60
|
+
throw new Error(`заборонена схема ${url.protocol} (лише http/https)`)
|
|
61
|
+
}
|
|
62
|
+
const host = url.hostname.toLowerCase()
|
|
63
|
+
if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) {
|
|
64
|
+
throw new Error(`заборонений хост: ${host}`)
|
|
65
|
+
}
|
|
66
|
+
const bare = host.replaceAll(/^\[|\]$/g, '')
|
|
67
|
+
if (PRIVATE_V4.test(bare)) throw new Error(`приватна IPv4-адреса заборонена: ${host}`)
|
|
68
|
+
if (PRIVATE_V6.test(bare)) throw new Error(`приватна IPv6-адреса заборонена: ${host}`)
|
|
69
|
+
return url
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ітеративно (без regex-backtracking) вирізає блоки `<tag …>…</tag>`.
|
|
74
|
+
* @param {string} html вихідний html
|
|
75
|
+
* @param {string} tag імʼя тега (lowercase)
|
|
76
|
+
* @returns {string} html без блоків тега
|
|
77
|
+
*/
|
|
78
|
+
function stripTagBlocks(html, tag) {
|
|
79
|
+
const open = `<${tag}`
|
|
80
|
+
const close = `</${tag}>`
|
|
81
|
+
const lower = () => html.toLowerCase()
|
|
82
|
+
for (;;) {
|
|
83
|
+
const start = lower().indexOf(open)
|
|
84
|
+
if (start === -1) return html
|
|
85
|
+
const end = lower().indexOf(close, start)
|
|
86
|
+
if (end === -1) return html.slice(0, start)
|
|
87
|
+
html = `${html.slice(0, start)} ${html.slice(end + close.length)}`
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Char-scan стрип решти тегів (`<…>` → пробіл) без regex.
|
|
93
|
+
* @param {string} text html після блокових замін
|
|
94
|
+
* @returns {string} текст без тегів
|
|
95
|
+
*/
|
|
96
|
+
function stripTags(text) {
|
|
97
|
+
let out = ''
|
|
98
|
+
let i = 0
|
|
99
|
+
while (i < text.length) {
|
|
100
|
+
const lt = text.indexOf('<', i)
|
|
101
|
+
if (lt === -1) {
|
|
102
|
+
out += text.slice(i)
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
out += text.slice(i, lt)
|
|
106
|
+
const gt = text.indexOf('>', lt + 1)
|
|
107
|
+
if (gt === -1) break
|
|
108
|
+
out += ' '
|
|
109
|
+
i = gt + 1
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Мінімальна html→text екстракція: викидає script/style/noscript, блокові теги
|
|
116
|
+
* зводить до переносів, решту тегів стрипає, декодує базові entity, стискає
|
|
117
|
+
* порожні рядки. Це свідомо НЕ readability (без DOM-залежностей) — достатньо,
|
|
118
|
+
* щоб агент прочитав документацію/README/чейнджлог.
|
|
119
|
+
* @param {string} html сирий html
|
|
120
|
+
* @returns {string} текст
|
|
121
|
+
*/
|
|
122
|
+
export function htmlToText(html) {
|
|
123
|
+
let text = html
|
|
124
|
+
for (const tag of ['script', 'style', 'noscript']) text = stripTagBlocks(text, tag)
|
|
125
|
+
text = text.replaceAll(BLOCK_CLOSE_TAGS, '\n').replaceAll(BR_HR_TAGS, '\n')
|
|
126
|
+
return stripTags(text)
|
|
127
|
+
.replaceAll(' ', ' ')
|
|
128
|
+
.replaceAll('&', '&')
|
|
129
|
+
.replaceAll('<', '<')
|
|
130
|
+
.replaceAll('>', '>')
|
|
131
|
+
.replaceAll('"', '"')
|
|
132
|
+
.replaceAll(''', "'")
|
|
133
|
+
.replaceAll(SPACES, ' ')
|
|
134
|
+
.replaceAll(BLANK_LINES, '\n\n')
|
|
135
|
+
.trim()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fetch з SSRF-guard на кожному redirect-hop-і, таймаутом і лімітом розміру.
|
|
140
|
+
* @param {string} rawUrl цільовий URL
|
|
141
|
+
* @param {{ timeoutMs?: number, maxChars?: number, fetchImpl?: typeof fetch }} [opts] параметри
|
|
142
|
+
* @returns {Promise<{ url: string, status: number, contentType: string, text: string, truncated: boolean }>} сторінка текстом
|
|
143
|
+
*/
|
|
144
|
+
export async function fetchPage(
|
|
145
|
+
rawUrl,
|
|
146
|
+
{ timeoutMs = FETCH_TIMEOUT_MS, maxChars = DEFAULT_MAX_CHARS, fetchImpl = fetch } = {}
|
|
147
|
+
) {
|
|
148
|
+
let url = assertPublicHttpUrl(rawUrl)
|
|
149
|
+
const controller = new AbortController()
|
|
150
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
151
|
+
try {
|
|
152
|
+
let response
|
|
153
|
+
for (let hop = 0; ; hop++) {
|
|
154
|
+
response = await fetchImpl(url, { redirect: 'manual', signal: controller.signal })
|
|
155
|
+
if (response.status < 300 || response.status >= 400) break
|
|
156
|
+
if (hop >= MAX_REDIRECTS) throw new Error(`занадто багато redirect-ів (> ${MAX_REDIRECTS})`)
|
|
157
|
+
const location = response.headers.get('location')
|
|
158
|
+
if (!location) throw new Error(`redirect ${response.status} без Location`)
|
|
159
|
+
url = assertPublicHttpUrl(new URL(location, url).href)
|
|
160
|
+
}
|
|
161
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
162
|
+
const raw = await response.text()
|
|
163
|
+
const text = HTML_CONTENT_TYPE.test(contentType) ? htmlToText(raw) : raw
|
|
164
|
+
return {
|
|
165
|
+
url: url.href,
|
|
166
|
+
status: response.status,
|
|
167
|
+
contentType,
|
|
168
|
+
text: text.slice(0, maxChars),
|
|
169
|
+
truncated: text.length > maxChars
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
clearTimeout(timer)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Search-адаптери: нормалізують відповідь до [{title, url, snippet}]. */
|
|
177
|
+
const PROVIDERS = {
|
|
178
|
+
brave: {
|
|
179
|
+
keyVar: 'BRAVE_API_KEY',
|
|
180
|
+
async search(query, count, key, fetchImpl) {
|
|
181
|
+
const u = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`
|
|
182
|
+
const r = await fetchImpl(u, { headers: { 'X-Subscription-Token': key, Accept: 'application/json' } })
|
|
183
|
+
if (!r.ok) throw new Error(`brave: HTTP ${r.status}`)
|
|
184
|
+
const data = await r.json()
|
|
185
|
+
return (data.web?.results ?? []).map(x => ({ title: x.title, url: x.url, snippet: x.description ?? '' }))
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
tavily: {
|
|
189
|
+
keyVar: 'TAVILY_API_KEY',
|
|
190
|
+
async search(query, count, key, fetchImpl) {
|
|
191
|
+
const r = await fetchImpl('https://api.tavily.com/search', {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
194
|
+
body: JSON.stringify({ query, max_results: count })
|
|
195
|
+
})
|
|
196
|
+
if (!r.ok) throw new Error(`tavily: HTTP ${r.status}`)
|
|
197
|
+
const data = await r.json()
|
|
198
|
+
return (data.results ?? []).map(x => ({ title: x.title, url: x.url, snippet: x.content ?? '' }))
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
exa: {
|
|
202
|
+
keyVar: 'EXA_API_KEY',
|
|
203
|
+
async search(query, count, key, fetchImpl) {
|
|
204
|
+
const r = await fetchImpl('https://api.exa.ai/search', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
|
|
207
|
+
body: JSON.stringify({ query, numResults: count, contents: { text: { maxCharacters: 500 } } })
|
|
208
|
+
})
|
|
209
|
+
if (!r.ok) throw new Error(`exa: HTTP ${r.status}`)
|
|
210
|
+
const data = await r.json()
|
|
211
|
+
return (data.results ?? []).map(x => ({ title: x.title ?? x.url, url: x.url, snippet: x.text ?? '' }))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Обирає search-провайдера: явний `N_LLM_SEARCH_PROVIDER` або перший наявний ключ.
|
|
218
|
+
* @param {Record<string, string|undefined>} env середовище
|
|
219
|
+
* @returns {{ name: string, key: string }|null} провайдер+ключ або null (жодного)
|
|
220
|
+
*/
|
|
221
|
+
export function resolveSearchProvider(env = processEnv) {
|
|
222
|
+
const wanted = env.N_LLM_SEARCH_PROVIDER
|
|
223
|
+
if (wanted) {
|
|
224
|
+
const p = PROVIDERS[wanted]
|
|
225
|
+
const key = p ? env[p.keyVar] : undefined
|
|
226
|
+
return p && key ? { name: wanted, key } : null
|
|
227
|
+
}
|
|
228
|
+
for (const [name, p] of Object.entries(PROVIDERS)) {
|
|
229
|
+
if (env[p.keyVar]) return { name, key: env[p.keyVar] }
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Структурована текстова відмова tool-виклику.
|
|
236
|
+
* @param {object} payload обʼєкт з `error`
|
|
237
|
+
* @returns {{ content: Array<{ type: string, text: string }>, details: object }} tool-результат
|
|
238
|
+
*/
|
|
239
|
+
function toolFail(payload) {
|
|
240
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload) }], details: {} }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Успішний текстовий tool-результат.
|
|
245
|
+
* @param {string} text текст відповіді
|
|
246
|
+
* @returns {{ content: Array<{ type: string, text: string }>, details: object }} tool-результат
|
|
247
|
+
*/
|
|
248
|
+
function toolOk(text) {
|
|
249
|
+
return { content: [{ type: 'text', text }], details: {} }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Фабрика пари pi-tools `web_search`/`web_fetch`.
|
|
254
|
+
* @param {{ defineTool: (def: object) => object,
|
|
255
|
+
* deps?: { env?: Record<string, string|undefined>, fetchImpl?: typeof fetch } }} args контекст:
|
|
256
|
+
* pi defineTool + інжекції env/fetch для тестів
|
|
257
|
+
* @returns {{ searchTool: object, fetchTool: object }} tool-дефініції для customTools
|
|
258
|
+
*/
|
|
259
|
+
export function createWebTools({ defineTool, deps = {} }) {
|
|
260
|
+
const env = deps.env ?? processEnv
|
|
261
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
262
|
+
|
|
263
|
+
const searchTool = defineTool({
|
|
264
|
+
name: 'web_search',
|
|
265
|
+
label: 'Web search',
|
|
266
|
+
description: 'Search the web. Returns a JSON list of {title, url, snippet}. Use web_fetch to read a result page.',
|
|
267
|
+
parameters: {
|
|
268
|
+
type: 'object',
|
|
269
|
+
properties: {
|
|
270
|
+
query: { type: 'string', description: 'search query' },
|
|
271
|
+
count: { type: 'number', description: `max results (default ${DEFAULT_SEARCH_COUNT})` }
|
|
272
|
+
},
|
|
273
|
+
required: ['query']
|
|
274
|
+
},
|
|
275
|
+
execute: async (_id, { query, count }) => {
|
|
276
|
+
const provider = resolveSearchProvider(env)
|
|
277
|
+
if (!provider) {
|
|
278
|
+
return toolFail({
|
|
279
|
+
error:
|
|
280
|
+
'search-провайдер не сконфігуровано: потрібен BRAVE_API_KEY / TAVILY_API_KEY / EXA_API_KEY (опц. N_LLM_SEARCH_PROVIDER)'
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const results = await PROVIDERS[provider.name].search(
|
|
285
|
+
query,
|
|
286
|
+
count ?? DEFAULT_SEARCH_COUNT,
|
|
287
|
+
provider.key,
|
|
288
|
+
fetchImpl
|
|
289
|
+
)
|
|
290
|
+
return toolOk(JSON.stringify({ provider: provider.name, results }))
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return toolFail({ error: `web_search: ${error.message}` })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const fetchTool = defineTool({
|
|
298
|
+
name: 'web_fetch',
|
|
299
|
+
label: 'Web fetch',
|
|
300
|
+
description:
|
|
301
|
+
'Fetch a public http(s) URL and return its text content (html is stripped to text). Truncated to a size limit.',
|
|
302
|
+
parameters: {
|
|
303
|
+
type: 'object',
|
|
304
|
+
properties: {
|
|
305
|
+
url: { type: 'string', description: 'absolute http(s) URL' },
|
|
306
|
+
maxChars: { type: 'number', description: `truncate limit (default ${DEFAULT_MAX_CHARS})` }
|
|
307
|
+
},
|
|
308
|
+
required: ['url']
|
|
309
|
+
},
|
|
310
|
+
execute: async (_id, { url, maxChars }) => {
|
|
311
|
+
try {
|
|
312
|
+
const page = await fetchPage(url, { maxChars: maxChars ?? DEFAULT_MAX_CHARS, fetchImpl })
|
|
313
|
+
return toolOk(JSON.stringify(page))
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return toolFail({ error: `web_fetch: ${error.message}` })
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
return { searchTool, fetchTool }
|
|
321
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7n/llm-lib",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Тонкий шар роботи з LLM (локальні omlx + хмарні провайдери) поверх pi: model tiers, one-shot, agentic-раннери, write-guard, trace, telemetry, prompt-budget",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nitra",
|