@7n/llm-lib 2.2.1 → 2.4.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 +12 -0
- package/README.md +3 -0
- package/lib/agent-fix.mjs +13 -1
- package/lib/docs/agent-fix.md +3 -1
- package/lib/docs/harness.md +31 -0
- package/lib/docs/web-tools.md +37 -0
- package/lib/harness.mjs +119 -0
- package/lib/web-tools.mjs +321 -0
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.0] - 2026-07-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- harness (Фаза A4): createHarness — декларативний фасад над runOneShot/runAgentFix/runAgentSkill (профіль-обʼєкт {schema_version, kind, ...} → делегація в раннер, per-виклик поля перекривають); + subpath-експорти anchored-edit, web-tools
|
|
8
|
+
|
|
9
|
+
## [2.3.0] - 2026-07-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 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)
|
|
14
|
+
|
|
3
15
|
## [2.2.1] - 2026-07-11
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ pi свідомо лишає на caller-а: model tiers, fail-fast політи
|
|
|
23
23
|
|
|
24
24
|
| Імпорт | Що дає |
|
|
25
25
|
| --- | --- |
|
|
26
|
+
| `@7n/llm-lib/harness` | `createHarness({profiles})` → `{run(spec), profileNames()}` — декларативний фасад над раннерами (профіль-обʼєкт `{schema_version, kind, ...}` → делегація); `validateProfile(p)`, `HARNESS_SCHEMA_VERSION` |
|
|
26
27
|
| `@7n/llm-lib/one-shot` | `runOneShot({messages, modelTier?, modelSpec?, ...})` → `{content, usage, error, model, caller}` |
|
|
27
28
|
| `@7n/llm-lib/agent-fix` | `runAgentFix(ruleId, violation, cwd, opts)` → `{applied, touchedFiles, telemetry, error, rollback}`; `buildFixPrompt(...)` |
|
|
28
29
|
| `@7n/llm-lib/agent-skill` | `runAgentSkill(prompt, opts)` → `{ok, telemetry, error}` |
|
|
@@ -35,6 +36,8 @@ pi свідомо лишає на caller-а: model tiers, fail-fast політи
|
|
|
35
36
|
| `@7n/llm-lib/with-timeout` | `withTimeout(promise, ms, {onTimeout?, label?})` |
|
|
36
37
|
| `@7n/llm-lib/prompt-budget` | `budgetFor(kind)`, `fitToBudget(chunks, maxChars)`, `packBatch(items, maxChars)`, `capText(text, maxChars)` |
|
|
37
38
|
| `@7n/llm-lib/body-capture` | `captureBody(record, opts?)` (opt-in, `N_LLM_TRACE_BODIES=1`), `bodiesDir()`, `bodyCaptureEnabled()` |
|
|
39
|
+
| `@7n/llm-lib/anchored-edit` | `createAnchoredTools({cwd, defineTool})`, `applyAnchoredEdits(content, edits)`, `lineAnchor(text)`, `renderAnchored(content, range?)` — hash-anchored строгі edit-tools (профіль `anchoredEdits`) |
|
|
40
|
+
| `@7n/llm-lib/web-tools` | `createWebTools({defineTool})`, `fetchPage(url, opts?)`, `assertPublicHttpUrl(url)`, `resolveSearchProvider(env)` — web_search/web_fetch із SSRF-guard (профіль `webTools`) |
|
|
38
41
|
|
|
39
42
|
`lib/internal/` (registry, memory-guard, max-tokens, chain-headers, compress-context,
|
|
40
43
|
apply-compression) — НЕ публічний API: не імпортувати зовні пакета, subpath-експортів
|
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,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: harness.mjs
|
|
4
|
+
resource: llm-lib/lib/harness.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 3ff88af8
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Огляд
|
|
10
|
+
|
|
11
|
+
Run-harness фасад (Фаза A4): єдиний декларативний вхід над трьома раннерами пакета (`runOneShot` / `runAgentFix` / `runAgentSkill`). Consumer описує ЩО запустити профілем-обʼєктом, а не набором позиційних opts; той самий профіль серіалізується у JSON — це те, що дозволяє майбутньому MT-адаптеру мапити вузол графа на конфігурацію без коду. Фасад тонкий: резолвить профіль у opts і делегує в наявний раннер, не дублюючи їхньої логіки (write-guard, verify-loop, toolset-и лишаються в раннерах).
|
|
12
|
+
|
|
13
|
+
## Поведінка
|
|
14
|
+
|
|
15
|
+
Профіль — обʼєкт `{ schema_version, kind, …налаштування }`, де `kind` (`fix`/`skill`/`one-shot`) привʼязує його до раннера, а решта полів (tier, model, timeoutMs, maxTokens, thinkingLevel, verifyMax, anchoredEdits, webTools) стають дефолтами opts. `schema_version` присутній з першої версії й перевіряється на сумісність — несумісний або невідомий `kind` дає структуровану помилку валідації ще до раннера.
|
|
16
|
+
|
|
17
|
+
`createHarness({ profiles })` повертає обʼєкт із `run(spec)` і `profileNames()`. У `run` профіль задається іменем (з мапи) або інлайн-обʼєктом, а per-виклик поля (динаміка: cwd, violation, verify, messages, prompt тощо) зливаються поверх дефолтів профілю й перекривають збіжні. Далі harness делегує з правильними позиційними аргументами кожного раннера: `fix` → `(ruleId, violation, cwd, opts)`, `skill` → `(prompt, opts)`, `one-shot` → `(opts)`. Поля `kind`/`schema_version` у opts раннера не потрапляють.
|
|
18
|
+
|
|
19
|
+
Раннери тягнуться lazy (динамічний import у гілці потрібного `kind`) — top-level модуль лишається вільним від pi; у тестах раннери інжектуються через `deps`.
|
|
20
|
+
|
|
21
|
+
## Публічний API
|
|
22
|
+
|
|
23
|
+
HARNESS_SCHEMA_VERSION — поточна версія схеми профілю.
|
|
24
|
+
validateProfile — перевіряє `kind` і `schema_version`, повертає `{ok}` або `{ok:false, error}`.
|
|
25
|
+
createHarness — будує harness із іменованих профілів; `run(spec)` запускає задачу, `profileNames()` перелічує профілі.
|
|
26
|
+
|
|
27
|
+
## Гарантії поведінки
|
|
28
|
+
|
|
29
|
+
- Контракт раннерів не змінюється: harness лише перекладає профіль+виклик у їхні аргументи й повертає їхній результат як є.
|
|
30
|
+
- Невалідний/невідомий профіль зупиняється до виклику раннера (жодного часткового ефекту).
|
|
31
|
+
- Top-level pi-free: жодного pi-import, поки не викликано `run` відповідного kind.
|
|
@@ -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) інжектовані — тести без мережі.
|
package/lib/harness.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** @see ./docs/harness.md */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run-harness фасад (Фаза A4, дизайн 2026-07-11): єдиний декларативний вхід над
|
|
5
|
+
* трьома раннерами (`runOneShot` / `runAgentFix` / `runAgentSkill`).
|
|
6
|
+
*
|
|
7
|
+
* Мета — щоб consumer (inline-драбина n-cursor, майбутній MT-runner Фази B, 7n-test)
|
|
8
|
+
* описував ЩО запустити **профілем-обʼєктом**, а не набором позиційних opts, і щоб
|
|
9
|
+
* той самий профіль серіалізувався у JSON (Фаза B мапить `a.md`-вузол MT → профіль
|
|
10
|
+
* без коду). Фасад тонкий: він резолвить профіль у opts і делегує в наявний раннер,
|
|
11
|
+
* НЕ дублюючи їхню логіку (write-guard, verify-loop, toolset-и лишаються в раннерах).
|
|
12
|
+
*
|
|
13
|
+
* Профіль (усі поля опційні, крім прив'язки до раннера через `kind`):
|
|
14
|
+
* { schema_version: 1, kind: 'fix'|'skill'|'one-shot',
|
|
15
|
+
* tier, model, timeoutMs, maxTokens, thinkingLevel,
|
|
16
|
+
* verifyMax, anchoredEdits, webTools }
|
|
17
|
+
* `schema_version` присутній з дня 1 — Фаза B хоче стабільності контракту.
|
|
18
|
+
*
|
|
19
|
+
* Модуль pi-free на рівні top-level: раннери самі роблять lazy pi-import у fix/skill
|
|
20
|
+
* гілці; `createHarness` лише готує замикання.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Поточна версія схеми профілю. Несумісна зміна → bump + міграція consumer-ів. */
|
|
24
|
+
export const HARNESS_SCHEMA_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** Підтримувані види задач (прив'язка профілю до раннера). */
|
|
27
|
+
const KINDS = new Set(['fix', 'skill', 'one-shot'])
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Валідує профіль: відомий `kind`, сумісний `schema_version`.
|
|
31
|
+
* @param {object} profile профіль-обʼєкт
|
|
32
|
+
* @returns {{ ok: true } | { ok: false, error: string }} результат валідації
|
|
33
|
+
*/
|
|
34
|
+
export function validateProfile(profile) {
|
|
35
|
+
if (!profile || typeof profile !== 'object') return { ok: false, error: 'профіль має бути обʼєктом' }
|
|
36
|
+
const v = profile.schema_version ?? HARNESS_SCHEMA_VERSION
|
|
37
|
+
if (v !== HARNESS_SCHEMA_VERSION) {
|
|
38
|
+
return { ok: false, error: `несумісний schema_version ${v} (очікується ${HARNESS_SCHEMA_VERSION})` }
|
|
39
|
+
}
|
|
40
|
+
if (!KINDS.has(profile.kind)) {
|
|
41
|
+
return { ok: false, error: `невідомий kind "${profile.kind}" (допустимі: ${[...KINDS].join(', ')})` }
|
|
42
|
+
}
|
|
43
|
+
return { ok: true }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Резолвить профіль + per-виклик поля у opts конкретного раннера.
|
|
48
|
+
* Профіль задає дефолти конфігурації, `call` — динаміку виклику (cwd, violation,
|
|
49
|
+
* verify, chain тощо); `call` перекриває збіжні поля профілю.
|
|
50
|
+
* @param {object} profile профіль-обʼєкт (валідований)
|
|
51
|
+
* @param {object} call per-виклик поля
|
|
52
|
+
* @returns {object} opts для раннера
|
|
53
|
+
*/
|
|
54
|
+
function resolveOpts(profile, call) {
|
|
55
|
+
const { schema_version: _sv, kind: _kind, ...profileOpts } = profile
|
|
56
|
+
return { ...profileOpts, ...call }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Створює harness із набором іменованих профілів.
|
|
61
|
+
* @param {{ profiles?: Record<string, object>,
|
|
62
|
+
* deps?: { runOneShot?: (opts: object) => Promise<object>, runAgentFix?: (...args: unknown[]) => Promise<object>, runAgentSkill?: (prompt: string, opts: object) => Promise<object> } }} [args]
|
|
63
|
+
* `profiles` — мапа імʼя→профіль; `deps` — інжекція раннерів (тести / кастомний wiring).
|
|
64
|
+
* @returns {{ run: (spec: object) => Promise<object>, profileNames: () => string[] }} harness
|
|
65
|
+
*/
|
|
66
|
+
export function createHarness({ profiles = {}, deps = {} } = {}) {
|
|
67
|
+
/**
|
|
68
|
+
* Лениво тягне раннер (pi-free top-level: імпорт лише при першому виклику потрібного kind).
|
|
69
|
+
* @param {string} kind вид задачі
|
|
70
|
+
* @returns {Promise<(...args: unknown[]) => Promise<object>>} функція-раннер
|
|
71
|
+
*/
|
|
72
|
+
async function runnerFor(kind) {
|
|
73
|
+
if (kind === 'fix') {
|
|
74
|
+
if (deps.runAgentFix) return deps.runAgentFix
|
|
75
|
+
const mod = await import('./agent-fix.mjs')
|
|
76
|
+
return mod.runAgentFix
|
|
77
|
+
}
|
|
78
|
+
if (kind === 'skill') {
|
|
79
|
+
if (deps.runAgentSkill) return deps.runAgentSkill
|
|
80
|
+
const mod = await import('./agent-skill.mjs')
|
|
81
|
+
return mod.runAgentSkill
|
|
82
|
+
}
|
|
83
|
+
if (deps.runOneShot) return deps.runOneShot
|
|
84
|
+
const mod = await import('./one-shot.mjs')
|
|
85
|
+
return mod.runOneShot
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Запускає задачу за профілем.
|
|
90
|
+
* @param {object} spec `{ profile: string|object, ...call }` — профіль за іменем або
|
|
91
|
+
* інлайн-обʼєктом + per-виклик поля (`fix`: ruleId, violation, cwd, verify, targetFiles…;
|
|
92
|
+
* `skill`: prompt, cwd…; `one-shot`: messages…).
|
|
93
|
+
* @returns {Promise<object>} результат відповідного раннера (контракт не змінюється)
|
|
94
|
+
*/
|
|
95
|
+
async function run(spec = {}) {
|
|
96
|
+
const { profile: profileRef, ...call } = spec
|
|
97
|
+
const profile = typeof profileRef === 'string' ? profiles[profileRef] : profileRef
|
|
98
|
+
if (!profile) throw new Error(`профіль не знайдено: ${JSON.stringify(profileRef)}`)
|
|
99
|
+
const valid = validateProfile(profile)
|
|
100
|
+
if (!valid.ok) throw new Error(`невалідний профіль: ${valid.error}`)
|
|
101
|
+
|
|
102
|
+
const run = await runnerFor(profile.kind)
|
|
103
|
+
const opts = resolveOpts(profile, call)
|
|
104
|
+
if (profile.kind === 'fix') {
|
|
105
|
+
// runAgentFix(ruleId, violation, cwd, opts) — позиційні + opts.
|
|
106
|
+
const { ruleId, violation, cwd, ...rest } = opts
|
|
107
|
+
return run(ruleId, violation, cwd, rest)
|
|
108
|
+
}
|
|
109
|
+
if (profile.kind === 'skill') {
|
|
110
|
+
// runAgentSkill(prompt, opts) — prompt позиційний.
|
|
111
|
+
const { prompt, ...rest } = opts
|
|
112
|
+
return run(prompt, rest)
|
|
113
|
+
}
|
|
114
|
+
// one-shot: усе в одному obj-arg.
|
|
115
|
+
return run(opts)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { run, profileNames: () => Object.keys(profiles) }
|
|
119
|
+
}
|
|
@@ -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.4.0",
|
|
4
4
|
"description": "Тонкий шар роботи з LLM (локальні omlx + хмарні провайдери) поверх pi: model tiers, one-shot, agentic-раннери, write-guard, trace, telemetry, prompt-budget",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nitra",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"!**/*.test.mjs"
|
|
33
33
|
],
|
|
34
34
|
"exports": {
|
|
35
|
+
"./harness": "./lib/harness.mjs",
|
|
36
|
+
"./anchored-edit": "./lib/anchored-edit.mjs",
|
|
37
|
+
"./web-tools": "./lib/web-tools.mjs",
|
|
35
38
|
"./model-tiers": "./lib/model-tiers.mjs",
|
|
36
39
|
"./chain": "./lib/chain.mjs",
|
|
37
40
|
"./chains-report": "./lib/chains-report.mjs",
|