@7n/test 0.12.1 → 0.13.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 +13 -0
- package/package.json +2 -1
- package/src/assess-need.mjs +2 -4
- package/src/coverage-classify/docs/index.md +1 -4
- package/src/coverage-classify/index.mjs +14 -12
- package/src/coverage-fix.mjs +9 -11
- package/src/docs/assess-need.md +1 -4
- package/src/docs/coverage-fix.md +1 -4
- package/src/docs/fix-tests.md +1 -5
- package/src/docs/gen-tests.md +1 -4
- package/src/fix-tests.mjs +4 -3
- package/src/gen-tests.mjs +3 -3
- package/src/lib/docs/index.md +1 -2
- package/src/lib/docs/llm.md +23 -0
- package/src/lib/llm.mjs +83 -0
- package/src/lib/docs/pi-client.md +0 -29
- package/src/lib/docs/prompt-budget.md +0 -34
- package/src/lib/pi-client.mjs +0 -243
- package/src/lib/prompt-budget.mjs +0 -131
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.13.0] - 2026-07-05
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- lock
|
|
8
|
+
- Ф3 виносу LLM-шару: @7n/test переведено на @nitra/llm-lib. pi-client.mjs (власний SDK-плюмбінг, exponential backoff, memory-retry 15/30/60с) і prompt-budget.mjs видалено; замість них тонкий адаптер lib/llm.mjs (callText/callAgent) поверх runOneShot/runAgentSkill пакета і budgetFor/capText/packBatch з @nitra/llm-lib/prompt-budget. Політика строго fail-fast: knobs N_PI_RETRY_*/N_PI_MEMORY_RETRY_* видалено, лишилось одноразове подвоєння maxTokens на stopReason:'length'. coverage-fix переведено зі spawnSync pi CLI на SDK-embed агента. Тир-резолюцію централізовано (LOCAL_MIN/CLOUD_MIN/CLOUD_MAX з @nitra/llm-lib/model-tiers; classify приймає інжектовні tier1/tier2). УВАГА: потребує опублікованого @nitra/llm-lib >= 1.0.0 (мердж cursor#llm-lib-extraction перед релізом).
|
|
9
|
+
|
|
10
|
+
## [0.12.2] - 2026-07-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- test(core): інваріантні тести маршрутизації gen-tests (local→cloud fallback, complex→cloud) замість сирих лічильників викликів; переписаний тест n-cursor-adr pi-extension
|
|
15
|
+
|
|
3
16
|
## [0.12.1] - 2026-07-04
|
|
4
17
|
|
|
5
18
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7n/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "CLI-утиліта @7n/test",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"7n",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
43
43
|
"@nitra/check-env": "^4.2.1",
|
|
44
|
+
"@nitra/llm-lib": "^1.0.0",
|
|
44
45
|
"@vitest/coverage-v8": "^4.1.9",
|
|
45
46
|
"rollup": "^4.62.2",
|
|
46
47
|
"vitest": "^4.1.9",
|
package/src/assess-need.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
|
-
import { callText } from './lib/
|
|
9
|
+
import { callText } from './lib/llm.mjs'
|
|
10
10
|
|
|
11
11
|
const MAX_CONTENT_BYTES = 6000
|
|
12
12
|
|
|
@@ -36,13 +36,12 @@ const WIRING_RE = /^(import\b|export\s+(?:\{[^}]*\}|\*|type\b|interface\b|enum\b
|
|
|
36
36
|
* @returns {string}
|
|
37
37
|
*/
|
|
38
38
|
function stripComments(src) {
|
|
39
|
-
return src.
|
|
39
|
+
return src.replaceAll(/\/\*[\s\S]*?\*\//g, ' ').replaceAll(/\/\/[^\n]*/g, '')
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Fast local classifier — no I/O, no LLM.
|
|
44
44
|
* Returns a result object for obvious cases, or null when ambiguous.
|
|
45
|
-
*
|
|
46
45
|
* @param {string} content file source
|
|
47
46
|
* @returns {{ needsTests: boolean, reason: string } | null}
|
|
48
47
|
*/
|
|
@@ -109,7 +108,6 @@ async function assessOne(fileInfo, dir, callTextFn) {
|
|
|
109
108
|
* Assess a list of uncovered files: do they need tests?
|
|
110
109
|
* Obvious cases (re-exports, functions-with-branches) are resolved locally.
|
|
111
110
|
* Only ambiguous files trigger an LLM call.
|
|
112
|
-
*
|
|
113
111
|
* @param {Array<{file: string, pct: number}>} files
|
|
114
112
|
* @param {string} dir project root
|
|
115
113
|
* @param {{ callText?: Function }} [opts]
|
|
@@ -3,11 +3,8 @@ type: JS Module
|
|
|
3
3
|
title: index.mjs
|
|
4
4
|
resource: npm/src/coverage-classify/index.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: bc7320ff
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
-
score: 100
|
|
9
|
-
issues: judge:inaccurate:0.99
|
|
10
|
-
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
8
|
---
|
|
12
9
|
|
|
13
10
|
## Огляд
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* 3. Tier 1 fail → Tier 2 (N_CLOUD_MIN_MODEL через pi) → parseVerdict.
|
|
8
8
|
* 4. Tier 2 fail → conservative fallback worth-testing/confidence=0.
|
|
9
9
|
*/
|
|
10
|
-
import { env } from 'node:process'
|
|
11
10
|
import { join } from 'node:path'
|
|
12
11
|
|
|
13
|
-
import { callText } from '../lib/
|
|
12
|
+
import { callText } from '../lib/llm.mjs'
|
|
13
|
+
import { CLOUD_MIN, LOCAL_MIN } from '@nitra/llm-lib/model-tiers'
|
|
14
14
|
import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
|
|
15
15
|
import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
|
|
16
16
|
import { parseVerdict } from './verdict-schema.mjs'
|
|
@@ -29,22 +29,22 @@ const FALLBACK_VERDICT = {
|
|
|
29
29
|
* @returns {Promise<string>}
|
|
30
30
|
*/
|
|
31
31
|
function callModel(prompt, model, cwd) {
|
|
32
|
-
return callText(prompt, { cwd, ...(model
|
|
32
|
+
return callText(prompt, { cwd, ...(model && { model }) })
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Два тири:
|
|
36
|
+
* Два тири: tier1 (local-min) → tier2 (cloud-min) → FALLBACK_VERDICT.
|
|
37
37
|
* @param {{file: string, mutants: object[]}} group
|
|
38
38
|
* @param {object} mutant
|
|
39
39
|
* @param {string} cwd
|
|
40
40
|
* @param {(prompt: string, model: string, cwd: string) => Promise<string>} callModelFn
|
|
41
|
+
* @param {string} tier1 model-spec першого тиру ('' = pi-дефолт)
|
|
42
|
+
* @param {string} tier2 model-spec другого тиру ('' = pi-дефолт)
|
|
41
43
|
* @returns {Promise<object>} verdict
|
|
42
44
|
*/
|
|
43
|
-
async function classifyOne(group, mutant, cwd, callModelFn) {
|
|
45
|
+
async function classifyOne(group, mutant, cwd, callModelFn, tier1, tier2) {
|
|
44
46
|
const prompt = `${SYSTEM_PROMPT}\n\n${buildUserPrompt({ ...mutant, file: group.file }, cwd)}`
|
|
45
47
|
const loc = `${group.file}:${mutant.line}:${mutant.col}`
|
|
46
|
-
const tier1 = env.N_LOCAL_MIN_MODEL ?? ''
|
|
47
|
-
const tier2 = env.N_CLOUD_MIN_MODEL ?? ''
|
|
48
48
|
|
|
49
49
|
try {
|
|
50
50
|
const text = await callModelFn(prompt, tier1, cwd)
|
|
@@ -64,14 +64,16 @@ async function classifyOne(group, mutant, cwd, callModelFn) {
|
|
|
64
64
|
* Класифікує survived мутантів через pi (N_LOCAL_MIN_MODEL → N_CLOUD_MIN_MODEL → fallback).
|
|
65
65
|
* @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived
|
|
66
66
|
* @param {string} cwd
|
|
67
|
-
* @param {{cachePath?: string, callModel?: (prompt: string, model: string, cwd: string) => Promise<string
|
|
67
|
+
* @param {{cachePath?: string, callModel?: (prompt: string, model: string, cwd: string) => Promise<string>,
|
|
68
|
+
* tier1?: string, tier2?: string}} [opts] `tier1`/`tier2` — явні model-specs (дефолт: LOCAL_MIN/CLOUD_MIN пакета;
|
|
69
|
+
* інжектовні, бо тир-константи фіксуються при імпорті й у тестах не стабляться через env)
|
|
68
70
|
* @returns {Promise<Array<{key: string, verdict: object}>>}
|
|
69
71
|
*/
|
|
70
72
|
export async function classify(survived, cwd, opts = {}) {
|
|
71
73
|
const cachePath = opts.cachePath ?? join(cwd, 'npm/reports/coverage-classify.cache.json')
|
|
72
74
|
const callModelFn = opts.callModel ?? callModel
|
|
73
|
-
const tier1 =
|
|
74
|
-
const tier2 =
|
|
75
|
+
const tier1 = opts.tier1 ?? LOCAL_MIN
|
|
76
|
+
const tier2 = opts.tier2 ?? CLOUD_MIN
|
|
75
77
|
const cacheModel = `${tier1 || 'default'}+${tier2 || 'cloud'}`
|
|
76
78
|
|
|
77
79
|
const cache = readCache(cachePath)
|
|
@@ -93,11 +95,11 @@ export async function classify(survived, cwd, opts = {}) {
|
|
|
93
95
|
verdict: cached.verdict,
|
|
94
96
|
confidence: cached.confidence,
|
|
95
97
|
reason: cached.reason,
|
|
96
|
-
...(cached.suggestedTest
|
|
98
|
+
...(cached.suggestedTest && { suggestedTest: cached.suggestedTest })
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
if (!verdict) {
|
|
100
|
-
verdict = await classifyOne(group, mutant, cwd, callModelFn)
|
|
102
|
+
verdict = await classifyOne(group, mutant, cwd, callModelFn, tier1, tier2)
|
|
101
103
|
if (cacheKey) {
|
|
102
104
|
cache.entries[cacheKey] = { ...verdict, classifiedAt: new Date().toISOString() }
|
|
103
105
|
}
|
package/src/coverage-fix.mjs
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { readFile } from 'node:fs/promises'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
-
import { spawnSync } from 'node:child_process'
|
|
12
11
|
import { env } from 'node:process'
|
|
12
|
+
import { CLOUD_MAX } from '@nitra/llm-lib/model-tiers'
|
|
13
|
+
import { callAgent } from './lib/llm.mjs'
|
|
13
14
|
|
|
14
|
-
const MODEL = env.N_CURSOR_COVERAGE_FIX_MODEL ??
|
|
15
|
+
const MODEL = env.N_CURSOR_COVERAGE_FIX_MODEL ?? CLOUD_MAX
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @typedef {{line:number, col:number, mutantType:string, original:string, replacement:string}} MutantDetail
|
|
@@ -22,7 +23,7 @@ const MODEL = env.N_CURSOR_COVERAGE_FIX_MODEL ?? env.N_CLOUD_MAX_MODEL ?? ''
|
|
|
22
23
|
* Запускає pi-агента для написання тестів по вцілілих мутантах.
|
|
23
24
|
* @param {SurvivedFileGroup[]} survived вцілілі мутанти, згруповані по файлах
|
|
24
25
|
* @param {string} projectRoot абсолютний шлях до кореня проєкту
|
|
25
|
-
* @param {{ callPi?: (prompt: string, model: string, opts: { cwd: string }) => void }} [opts] ін'єкції для тестів
|
|
26
|
+
* @param {{ callPi?: (prompt: string, model: string, opts: { cwd: string }) => Promise<void> }} [opts] ін'єкції для тестів
|
|
26
27
|
* @returns {Promise<void>}
|
|
27
28
|
*/
|
|
28
29
|
export async function fixSurvivedMutants(survived, projectRoot, opts = {}) {
|
|
@@ -36,22 +37,19 @@ export async function fixSurvivedMutants(survived, projectRoot, opts = {}) {
|
|
|
36
37
|
console.log(`\n🤖 coverage --fix: запускаю агента для ${totalMutants} вцілілих мутантів...\n`)
|
|
37
38
|
|
|
38
39
|
const callPiFn = opts.callPi ?? callPi
|
|
39
|
-
callPiFn(prompt, MODEL, { cwd: projectRoot })
|
|
40
|
+
await callPiFn(prompt, MODEL, { cwd: projectRoot })
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Викликає
|
|
44
|
+
* Викликає агента через @nitra/llm-lib (SDK-embed, live-output до stdout) —
|
|
45
|
+
* заміна колишнього spawnSync pi CLI.
|
|
44
46
|
* @param {string} prompt текст промпта
|
|
45
47
|
* @param {string} model provider/model-id або '' для pi-дефолту
|
|
46
48
|
* @param {{ cwd?: string }} [piOpts] опційні параметри (cwd)
|
|
49
|
+
* @returns {Promise<void>} завершується після виконання агента
|
|
47
50
|
*/
|
|
48
51
|
function callPi(prompt, model, { cwd } = {}) {
|
|
49
|
-
|
|
50
|
-
spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session'], {
|
|
51
|
-
cwd,
|
|
52
|
-
stdio: 'inherit',
|
|
53
|
-
timeout: 900_000
|
|
54
|
-
})
|
|
52
|
+
return callAgent(prompt, cwd, { model })
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
/**
|
package/src/docs/assess-need.md
CHANGED
|
@@ -3,11 +3,8 @@ type: JS Module
|
|
|
3
3
|
title: assess-need.mjs
|
|
4
4
|
resource: npm/src/assess-need.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 960f8332
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
-
score: 90
|
|
9
|
-
issues: internal-name:callText,judge:inaccurate:0.98
|
|
10
|
-
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
8
|
---
|
|
12
9
|
|
|
13
10
|
## Огляд
|
package/src/docs/coverage-fix.md
CHANGED
|
@@ -3,11 +3,8 @@ type: JS Module
|
|
|
3
3
|
title: coverage-fix.mjs
|
|
4
4
|
resource: npm/src/coverage-fix.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: d0a20c67
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
-
score: 100
|
|
9
|
-
issues: judge:inaccurate:0.98
|
|
10
|
-
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
8
|
---
|
|
12
9
|
|
|
13
10
|
## Огляд
|
package/src/docs/fix-tests.md
CHANGED
|
@@ -3,12 +3,8 @@ type: JS Module
|
|
|
3
3
|
title: fix-tests.mjs
|
|
4
4
|
resource: npm/src/fix-tests.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 6597b7fc
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
-
tier: local-min-retry
|
|
9
|
-
score: 90
|
|
10
|
-
issues: internal-name:callText,judge:inaccurate:0.99
|
|
11
|
-
judgeModel: openai-codex/gpt-5.4-mini
|
|
12
8
|
---
|
|
13
9
|
|
|
14
10
|
## Огляд
|
package/src/docs/gen-tests.md
CHANGED
|
@@ -3,11 +3,8 @@ type: JS Module
|
|
|
3
3
|
title: gen-tests.mjs
|
|
4
4
|
resource: npm/src/gen-tests.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 99d3e9b7
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
-
score: 100
|
|
9
|
-
issues: judge:inaccurate:0.99
|
|
10
|
-
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
8
|
---
|
|
12
9
|
|
|
13
10
|
## Огляд
|
package/src/fix-tests.mjs
CHANGED
|
@@ -19,13 +19,14 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
19
19
|
import { tmpdir } from 'node:os'
|
|
20
20
|
import { join, relative } from 'node:path'
|
|
21
21
|
import { env } from 'node:process'
|
|
22
|
-
import { callText, MEMORY_ERROR_RE } from './lib/
|
|
23
|
-
import {
|
|
22
|
+
import { callText, MEMORY_ERROR_RE } from './lib/llm.mjs'
|
|
23
|
+
import { CLOUD_MAX } from '@nitra/llm-lib/model-tiers'
|
|
24
|
+
import { budgetFor, capText, packBatch } from '@nitra/llm-lib/prompt-budget'
|
|
24
25
|
import { findTestRules } from './gen-tests.mjs'
|
|
25
26
|
import { parseFailingTests } from './coverage-per-file.mjs'
|
|
26
27
|
import { resolveVitestRun } from './lib/vitest-shim.mjs'
|
|
27
28
|
|
|
28
|
-
const MODEL = env.N_CURSOR_FIX_TESTS_MODEL ??
|
|
29
|
+
const MODEL = env.N_CURSOR_FIX_TESTS_MODEL ?? (CLOUD_MAX || undefined)
|
|
29
30
|
const MAX_SRC_BYTES = 4000
|
|
30
31
|
const TEST_DIR_MARKERS = ['/tests/', '\\tests\\']
|
|
31
32
|
const TEST_FILE_SUFFIX = '.test.mjs'
|
package/src/gen-tests.mjs
CHANGED
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
17
|
import { spawnSync } from 'node:child_process'
|
|
18
18
|
import { join, relative, dirname } from 'node:path'
|
|
19
|
-
import { callText, MEMORY_ERROR_RE } from './lib/
|
|
20
|
-
import {
|
|
19
|
+
import { callText, MEMORY_ERROR_RE } from './lib/llm.mjs'
|
|
20
|
+
import { env } from 'node:process'
|
|
21
|
+
import { budgetFor } from '@nitra/llm-lib/prompt-budget'
|
|
21
22
|
import { resolveVitestRun } from './lib/vitest-shim.mjs'
|
|
22
23
|
import { extractExportsWithComplexity } from './classify-exports.mjs'
|
|
23
24
|
import { analyzeModule } from './lib/ast-analyze.mjs'
|
|
24
25
|
import { probeModule, probeFetchCalls, probeTimeVariants, probeHelpers } from './lib/runtime-probe.mjs'
|
|
25
|
-
import { env } from 'node:process'
|
|
26
26
|
|
|
27
27
|
const MAX_SRC_BYTES = 6000
|
|
28
28
|
|
package/src/lib/docs/index.md
CHANGED
|
@@ -9,7 +9,6 @@ resource: npm/src/lib/
|
|
|
9
9
|
| Файл | Тип |
|
|
10
10
|
| ------------------------------------- | --------- |
|
|
11
11
|
| [ast-analyze.mjs](ast-analyze.md) | JS Module |
|
|
12
|
-
| [
|
|
13
|
-
| [prompt-budget.mjs](prompt-budget.md) | JS Module |
|
|
12
|
+
| [llm.mjs](llm.md) | JS Module |
|
|
14
13
|
| [runtime-probe.mjs](runtime-probe.md) | JS Module |
|
|
15
14
|
| [vitest-shim.mjs](vitest-shim.md) | JS Module |
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: llm.mjs
|
|
4
|
+
resource: npm/src/lib/llm.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 41c6ec97
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Огляд
|
|
10
|
+
|
|
11
|
+
Тонкий адаптер @7n/test над пакетом `@nitra/llm-lib` (Ф3 спеки llm-lib-extraction у репо cursor): зберігає звичний контракт `callText`/`callAgent` для внутрішніх колерів (gen-tests, fix-tests, assess-need, coverage-classify, coverage-fix), а транспорт, registry і трасування повністю живуть у пакеті. Замінив колишній `pi-client.mjs` з власним SDK-плюмбінгом, retry/backoff і прямим omlx-HTTP.
|
|
12
|
+
|
|
13
|
+
## Поведінка
|
|
14
|
+
|
|
15
|
+
callText — одноразовий text-виклик без tools через `runOneShot`: повертає текст відповіді; кидає Error на будь-якій помилці виклику (fail-fast, без retry/backoff). Єдина локальна політика — одноразове подвоєння `maxTokens` (до стелі 32768) при обрізаній відповіді (`stopReason: 'length'`), без пауз.
|
|
16
|
+
callAgent — агентний виклик через `runAgentSkill` з повним tool-set (read/write/edit/bash/grep/find/ls): агент пише файли напряму, текст стрімиться у stdout; таймаут 900с (паритет зі старим spawnSync pi CLI); без стелі відповіді (`maxTokens: 0`). Кидає Error на помилці.
|
|
17
|
+
MEMORY_ERROR_RE — реекспорт з пакета: колери класифікують memory-guard помилку локального model-сервера (пробити нагору й завершити процес, а не ковтати як per-file помилку).
|
|
18
|
+
|
|
19
|
+
## Гарантії поведінки
|
|
20
|
+
|
|
21
|
+
- Жодних вбудованих retry/backoff: колишні knobs N_PI_RETRY_ATTEMPTS/N_PI_MEMORY_RETRY_ATTEMPTS тощо видалено (fail-fast політика пакета).
|
|
22
|
+
- Подвоєння maxTokens відбувається щонайбільше один раз на виклик.
|
|
23
|
+
- `opts.deps` прокидається у раннери пакета — тести інжектять фейк-сесії без pi.
|
package/src/lib/llm.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Тонкий адаптер @7n/test над @nitra/llm-lib (Ф3 спеки llm-lib-extraction):
|
|
3
|
+
* зберігає звичний контракт `callText`/`callAgent` для внутрішніх колерів,
|
|
4
|
+
* але транспорт/registry/trace повністю живуть у пакеті.
|
|
5
|
+
*
|
|
6
|
+
* Політика fail-fast успадковується від пакета: жодних retry/backoff на
|
|
7
|
+
* connection- чи memory-помилках (колишні knobs N_PI_RETRY_ATTEMPTS,
|
|
8
|
+
* N_PI_MEMORY_RETRY_ATTEMPTS тощо видалено разом із withRetry).
|
|
9
|
+
* Єдина локальна політика — одноразове
|
|
10
|
+
* подвоєння `maxTokens` на обрізаній відповіді (`stopReason: 'length'`):
|
|
11
|
+
* це семантичний повтор без пауз, а не очікування зайнятого сервера.
|
|
12
|
+
*/
|
|
13
|
+
import { MEMORY_ERROR_RE, runOneShot } from '@nitra/llm-lib/one-shot'
|
|
14
|
+
import { runAgentSkill } from '@nitra/llm-lib/agent-skill'
|
|
15
|
+
|
|
16
|
+
export { MEMORY_ERROR_RE }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Стеля відповіді моделі для подвоєння на `stopReason: 'length'` — межа
|
|
20
|
+
* `maxTokens` реєстру для локальної моделі (див. `~/.pi/agent/models.json`).
|
|
21
|
+
*/
|
|
22
|
+
const MAX_TOKENS_CEILING = 32_768
|
|
23
|
+
|
|
24
|
+
/** Таймаут агентного виклику (паритет зі старим spawnSync pi CLI). */
|
|
25
|
+
const AGENT_TIMEOUT_MS = 900_000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Одноразовий text-виклик (без tools). Кидає Error на будь-якій помилці
|
|
29
|
+
* виклику (колери класифікують memory-guard через `MEMORY_ERROR_RE`).
|
|
30
|
+
* @param {string} prompt текст запиту для моделі
|
|
31
|
+
* @param {object} [opts] додаткові параметри виклику
|
|
32
|
+
* @param {string} [opts.cwd] робоча директорія для session
|
|
33
|
+
* @param {string} [opts.model] provider/model-id (напр. "openai/gpt-5.5"); без значення — default pi
|
|
34
|
+
* @param {number} [opts.maxTokens] стеля відповіді для цього виклику; на
|
|
35
|
+
* `stopReason: 'length'` виклик повторюється один раз із подвоєною стелею
|
|
36
|
+
* @param {object} [opts.deps] інжекти для тестів (прокидаються у runOneShot)
|
|
37
|
+
* @returns {Promise<string>} текстова відповідь моделі
|
|
38
|
+
*/
|
|
39
|
+
export async function callText(prompt, opts = {}) {
|
|
40
|
+
const r = await runOneShot({
|
|
41
|
+
messages: [{ role: 'user', content: prompt }],
|
|
42
|
+
modelSpec: opts.model ?? '',
|
|
43
|
+
maxTokens: opts.maxTokens,
|
|
44
|
+
timeoutMs: 0,
|
|
45
|
+
cwd: opts.cwd,
|
|
46
|
+
caller: '7n-test:text',
|
|
47
|
+
deps: opts.deps
|
|
48
|
+
})
|
|
49
|
+
if (r.error) throw new Error(r.error)
|
|
50
|
+
|
|
51
|
+
// Обрізана генерація зі зниженою стелею — не палимо retry-цикли колера
|
|
52
|
+
// на «invalid block», а один раз повторюємо з подвоєною стелею.
|
|
53
|
+
if (r.stopReason === 'length' && opts.maxTokens && opts.maxTokens < MAX_TOKENS_CEILING && !opts._lengthRetried) {
|
|
54
|
+
const doubled = Math.min(opts.maxTokens * 2, MAX_TOKENS_CEILING)
|
|
55
|
+
console.log(` ⚠ відповідь обрізана (stopReason: length) — повтор із maxTokens ${opts.maxTokens} → ${doubled}`)
|
|
56
|
+
return callText(prompt, { ...opts, maxTokens: doubled, _lengthRetried: true })
|
|
57
|
+
}
|
|
58
|
+
return r.content
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Агентний виклик із повним tool-set (read/write/edit/bash/grep/find/ls):
|
|
63
|
+
* агент пише файли напряму, текст стрімиться у stdout. Кидає Error на
|
|
64
|
+
* помилці виклику. Заміна колишнього `spawnSync('pi', ['-p', ...])`.
|
|
65
|
+
* @param {string} prompt текст завдання для агента
|
|
66
|
+
* @param {string} cwd робоча директорія, куди агент може писати файли
|
|
67
|
+
* @param {object} [opts] додаткові параметри
|
|
68
|
+
* @param {string} [opts.model] provider/model-id або '' для pi-дефолту
|
|
69
|
+
* @param {object} [opts.deps] інжекти для тестів (прокидаються у runAgentSkill)
|
|
70
|
+
* @returns {Promise<void>} проміс завершується після виконання агента
|
|
71
|
+
*/
|
|
72
|
+
export async function callAgent(prompt, cwd, opts = {}) {
|
|
73
|
+
const r = await runAgentSkill(prompt, {
|
|
74
|
+
skillId: '7n-test',
|
|
75
|
+
modelSpec: opts.model ?? '',
|
|
76
|
+
cwd,
|
|
77
|
+
timeoutMs: AGENT_TIMEOUT_MS,
|
|
78
|
+
maxTokens: 0, // без стелі: агент пише цілі тест-файли (паритет зі старим CLI-шляхом)
|
|
79
|
+
caller: 'agent:7n-test',
|
|
80
|
+
deps: opts.deps
|
|
81
|
+
})
|
|
82
|
+
if (r.error) throw new Error(r.error)
|
|
83
|
+
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
type: JS Module
|
|
3
|
-
title: pi-client.mjs
|
|
4
|
-
resource: npm/src/lib/pi-client.mjs
|
|
5
|
-
docgen:
|
|
6
|
-
crc: e7f3471e
|
|
7
|
-
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
-
score: 100
|
|
9
|
-
issues: judge:inaccurate:0.98
|
|
10
|
-
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## Огляд
|
|
14
|
-
|
|
15
|
-
Цей модуль надає прямий, інпроцесний програмний інтерфейс для взаємодії з агентами pi SDK, замінюючи синхронні виклики зовнішнього процесу. Він реалізує два режими роботи: текстовий режим, де функція `callText` повертає рядок на основі текстового запиту без активації інструментів, та режим агента, де функція `callAgent` активує кодові інструменти для прямого внесення змін у файли у заданій робочій директорії. Зверніть увагу, що у цьому інтерфейсі відсутній механізм кешування.
|
|
16
|
-
|
|
17
|
-
## Поведінка
|
|
18
|
-
|
|
19
|
-
callText відправляє текстовий запит до моделі pi та повертає текстову відповідь.
|
|
20
|
-
callAgent відправляє завдання до агента pi з активними інструментами кодування та виконує зміни у файлах у вказаній директорії.
|
|
21
|
-
|
|
22
|
-
## Публічний API
|
|
23
|
-
|
|
24
|
-
callText — надсилає одноразовий запит у текстовому режимі, не використовуючи інструменти, і повертає відповідь, зчитуючи конфігурацію з ~/.pi/.
|
|
25
|
-
callAgent — надсилає запит у режимі агента, надаючи доступ до повного набору інструментів кодування (читання, запис, bash, редагування); агент сам створює тестові файли.
|
|
26
|
-
|
|
27
|
-
## Гарантії поведінки
|
|
28
|
-
|
|
29
|
-
- (специфічних машинно-виведених гарантій немає)
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
type: JS Module
|
|
3
|
-
title: prompt-budget.mjs
|
|
4
|
-
resource: npm/src/lib/prompt-budget.mjs
|
|
5
|
-
docgen:
|
|
6
|
-
crc: 010265bd
|
|
7
|
-
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
-
score: 100
|
|
9
|
-
issues: judge:inaccurate:0.99
|
|
10
|
-
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## Огляд
|
|
14
|
-
|
|
15
|
-
Слугує єдиною точкою правди для визначення ліміту символів промпту та стелі відповіді (`maxTokens`) залежно від типу задачі (наприклад, `gen-tests`, `fix-tests`). Це механізм захисту від надмірного розширення промпту. Надає функції для внутрішнього обрізання тексту (`fitToBudget`), що виключає низькопріоритетні частини промпту, щоб він вмістився у встановлений ліміт, а також для ефективного групування цілих одиниць вмісту (`packBatch`) для подальшої пакетної обробки.
|
|
16
|
-
|
|
17
|
-
## Поведінка
|
|
18
|
-
|
|
19
|
-
Поведінка
|
|
20
|
-
budgetFor повертає встановлений ліміт символів для промпту та максимальну кількість токенів для заданого типу LLM-задачі.
|
|
21
|
-
capText обрізає вхідний текст до заданого максимального розміру, зберігаючи його структуру з головною та хвостовою частинами, розділеними маркером.
|
|
22
|
-
fitToBudget збирає частини промпту, обрізаючи або відкидаючи нижчопріоритетні частини доти, доки сумарний обсяг не вкладеться у заданий ліміт символів, при цьому найвищий пріоритет завжди захищено.
|
|
23
|
-
packBatch групує одиниці (файли) у батчи, сортуючи їх за розміром, щоб вмістити якомога більше у межах заданого бюджету; одиниці, що не вмістилися, відкладаються для наступного проходу.
|
|
24
|
-
|
|
25
|
-
## Публічний API
|
|
26
|
-
|
|
27
|
-
budgetFor — надає бюджет, необхідний для певної категорії завдання.
|
|
28
|
-
capText — безпечно укорочує текст, зберігаючи його початок, маркер та кінець.
|
|
29
|
-
fitToBudget — розміщує блоки у межах заданого бюджету: спочатку зменшує вміст, а потім відкидає найнижчі за пріоритетом, поки обсяг не втиснеться. Найважливіший елемент завжди залишається повним.
|
|
30
|
-
packBatch — згруповує файли для обробки: спочатку обирає найменші для оптимізації кількості виправлень за один раз. Якщо елемент занадто великий, він відкладається для окремої обробки, де застосовується жорсткіше обрізання.
|
|
31
|
-
|
|
32
|
-
## Гарантії поведінки
|
|
33
|
-
|
|
34
|
-
- Read-only: не виконує операцій запису (ФС/БД).
|
package/src/lib/pi-client.mjs
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Programmatic pi client via `@earendil-works/pi-coding-agent` SDK.
|
|
3
|
-
* Replaces spawnSync('pi', ...) with direct in-process calls.
|
|
4
|
-
*
|
|
5
|
-
* Two modes:
|
|
6
|
-
* callText(prompt, model?) — text-only, no tools, returns string
|
|
7
|
-
* callAgent(prompt, cwd) — coding tools enabled, writes files directly
|
|
8
|
-
*
|
|
9
|
-
* Both retry transient connection failures (e.g. a shared local model server
|
|
10
|
-
* that's momentarily busy under concurrent load) with exponential backoff
|
|
11
|
-
* instead of failing the caller's file on the first hiccup. A memory-guard
|
|
12
|
-
* rejection gets its own longer bounded backoff: oMLX (local model server)
|
|
13
|
-
* одночасно, тож відмова зазвичай відображає залишкову пам'ять попереднього
|
|
14
|
-
* запиту, яку сервер вивільняє сам за десятки секунд. Лише після
|
|
15
|
-
* `N_PI_MEMORY_RETRY_ATTEMPTS` невдач тіло запиту друкується в stdout і
|
|
16
|
-
* помилка прокидається як термінальна (виклики-власники процесу мають дати
|
|
17
|
-
* їй впасти, не ковтати як звичайну per-file помилку).
|
|
18
|
-
*/
|
|
19
|
-
import { createAgentSession, SessionManager, ModelRegistry, AuthStorage } from '@earendil-works/pi-coding-agent'
|
|
20
|
-
import { randomInt } from 'node:crypto'
|
|
21
|
-
import { env } from 'node:process'
|
|
22
|
-
import { setTimeout as sleep } from 'node:timers/promises'
|
|
23
|
-
|
|
24
|
-
let _registry = null
|
|
25
|
-
/**
|
|
26
|
-
* Повертає або ініціалізує спільний model registry для поточного процесу.
|
|
27
|
-
* @returns {object} спільний registry
|
|
28
|
-
*/
|
|
29
|
-
function getRegistry() {
|
|
30
|
-
if (_registry) return _registry
|
|
31
|
-
_registry = ModelRegistry.create(AuthStorage.create())
|
|
32
|
-
return _registry
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const RETRYABLE_ERROR_RE = /connection error|ECONNREFUSED|ETIMEDOUT|fetch failed|network/i
|
|
36
|
-
const OMLX_CODE_RE = /"(?:omlx_)?code"\s*:\s*"([^"]+)"/
|
|
37
|
-
const OMLX_ESTIMATED_BYTES_RE = /"estimated_bytes"\s*:\s*(\d+)/
|
|
38
|
-
const OMLX_LIMIT_BYTES_RE = /"limit_bytes"\s*:\s*(\d+)/
|
|
39
|
-
/** Matches a local model server (e.g. oMLX) rejecting a prompt for lack of RAM. */
|
|
40
|
-
export const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
|
|
41
|
-
const MAX_ATTEMPTS = Number(env.N_PI_RETRY_ATTEMPTS) || 4
|
|
42
|
-
const BASE_DELAY_MS = Number(env.N_PI_RETRY_DELAY_MS) || 1500
|
|
43
|
-
const MAX_DELAY_MS = 15_000
|
|
44
|
-
/** Скільки разів пробуємо промпт, відкинутий memory guard (включно з першою спробою). */
|
|
45
|
-
const MEMORY_MAX_ATTEMPTS = Number(env.N_PI_MEMORY_RETRY_ATTEMPTS) || 3
|
|
46
|
-
/** Базова затримка перед повтором після memory-guard відмови (експоненційно: 15s → 30s → …). */
|
|
47
|
-
const MEMORY_BASE_DELAY_MS = Number(env.N_PI_MEMORY_RETRY_DELAY_MS) || 15_000
|
|
48
|
-
const MEMORY_MAX_DELAY_MS = 60_000
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Витягує структуровані поля oMLX-помилки з тексту повідомлення, якщо тіло
|
|
52
|
-
* 400 (`omlx_code`/`estimated_bytes`/`limit_bytes`) потрапило в message.
|
|
53
|
-
* `estimated_bytes`/`limit_bytes` — байти GPU/Metal-пам'яті сервера,
|
|
54
|
-
* ТІЛЬКИ для діагностики й логів (не «скільки символів промпту зрізати»).
|
|
55
|
-
* @param {string} message текст помилки від SDK/сервера
|
|
56
|
-
* @returns {{omlxCode?: string, estimatedBytes?: number, limitBytes?: number} | null} знайдені поля або `null`
|
|
57
|
-
*/
|
|
58
|
-
function parseOmlxErrorDetails(message) {
|
|
59
|
-
const code = OMLX_CODE_RE.exec(message)?.[1]
|
|
60
|
-
const estimated = OMLX_ESTIMATED_BYTES_RE.exec(message)?.[1]
|
|
61
|
-
const limit = OMLX_LIMIT_BYTES_RE.exec(message)?.[1]
|
|
62
|
-
if (!code && !estimated && !limit) return null
|
|
63
|
-
const details = {}
|
|
64
|
-
if (code) details.omlxCode = code
|
|
65
|
-
if (estimated) details.estimatedBytes = Number(estimated)
|
|
66
|
-
if (limit) details.limitBytes = Number(limit)
|
|
67
|
-
return details
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Розпізнає memory-guard відмову і збагачує помилку структурованими полями
|
|
72
|
-
* (`omlxCode`, `estimatedBytes`, `limitBytes`), коли вони доступні в message.
|
|
73
|
-
* @param {Error} error помилка з виклику моделі
|
|
74
|
-
* @returns {boolean} `true`, якщо це memory-guard відмова
|
|
75
|
-
*/
|
|
76
|
-
function isMemoryGuardError(error) {
|
|
77
|
-
const details = parseOmlxErrorDetails(error.message ?? '')
|
|
78
|
-
if (details) Object.assign(error, details)
|
|
79
|
-
return error.omlxCode === 'prefill_memory_exceeded' || MEMORY_ERROR_RE.test(error.message ?? '')
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Термінальний вихід після вичерпання memory-retry: друкує тіло запиту в
|
|
84
|
-
* stdout (лише тут — не на проміжних спробах) і кидає помилку далі,
|
|
85
|
-
* зберігаючи структуровані поля й оригінал у `cause`. Виклики-власники
|
|
86
|
-
* процесу (CLI) мають дати їй впасти, не ковтати як per-file помилку.
|
|
87
|
-
* @param {Error} error memory-guard помилка останньої спроби
|
|
88
|
-
* @param {string} requestBody промпт, надісланий моделі
|
|
89
|
-
* @returns {never} завжди кидає
|
|
90
|
-
*/
|
|
91
|
-
function failOnMemoryGuard(error, requestBody) {
|
|
92
|
-
console.log('--- omlx memory-guard: тіло запиту ---')
|
|
93
|
-
console.log(requestBody)
|
|
94
|
-
console.log(`✗ omlx memory-guard: ${error.message}`)
|
|
95
|
-
const wrapped = new Error(`omlx memory-guard: ${error.message}`, { cause: error })
|
|
96
|
-
for (const key of ['omlxCode', 'estimatedBytes', 'limitBytes']) {
|
|
97
|
-
if (error[key] !== undefined) wrapped[key] = error[key]
|
|
98
|
-
}
|
|
99
|
-
throw wrapped
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Retries `fn` with exponential backoff + jitter when it throws a transient
|
|
104
|
-
* connection-ish error (shared local model server busy/restarting); other
|
|
105
|
-
* errors (auth, malformed request) are re-thrown immediately. A memory-guard
|
|
106
|
-
* rejection has its own bounded schedule (`MEMORY_MAX_ATTEMPTS` спроб,
|
|
107
|
-
* затримки від `MEMORY_BASE_DELAY_MS` експоненційно): одно-слотова черга
|
|
108
|
-
* oMLX вивільняє залишкову пам'ять попереднього запиту сама, тож повтор
|
|
109
|
-
* того самого промпту після паузи легітимний. Після вичерпання —
|
|
110
|
-
* `failOnMemoryGuard` (друк тіла + термінальний throw).
|
|
111
|
-
* @template T
|
|
112
|
-
* @param {() => Promise<T>} fn operation to retry
|
|
113
|
-
* @param {string} requestBody prompt sent to the model, printed if a memory-guard error exhausts its retries
|
|
114
|
-
* @returns {Promise<T>} result of the first successful attempt
|
|
115
|
-
*/
|
|
116
|
-
async function withRetry(fn, requestBody) {
|
|
117
|
-
let memoryAttempts = 0
|
|
118
|
-
let connAttempts = 0
|
|
119
|
-
for (;;) {
|
|
120
|
-
try {
|
|
121
|
-
return await fn()
|
|
122
|
-
} catch (error) {
|
|
123
|
-
if (isMemoryGuardError(error)) {
|
|
124
|
-
memoryAttempts++
|
|
125
|
-
if (memoryAttempts >= MEMORY_MAX_ATTEMPTS) failOnMemoryGuard(error, requestBody)
|
|
126
|
-
const delay = Math.min(MEMORY_BASE_DELAY_MS * 2 ** (memoryAttempts - 1), MEMORY_MAX_DELAY_MS)
|
|
127
|
-
await sleep(delay * (0.5 + randomInt(5000) / 10000))
|
|
128
|
-
continue
|
|
129
|
-
}
|
|
130
|
-
connAttempts++
|
|
131
|
-
if (connAttempts >= MAX_ATTEMPTS || !RETRYABLE_ERROR_RE.test(error.message ?? '')) throw error
|
|
132
|
-
const delay = Math.min(BASE_DELAY_MS * 2 ** (connAttempts - 1), MAX_DELAY_MS)
|
|
133
|
-
const jitter = delay * (0.5 + randomInt(5000) / 10000)
|
|
134
|
-
await sleep(jitter)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Стеля відповіді моделі для подвоєння на `stopReason: 'length'` — межа
|
|
141
|
-
* `maxTokens` реєстру для локальної моделі (див. `~/.pi/agent/models.json`).
|
|
142
|
-
*/
|
|
143
|
-
const MAX_TOKENS_CEILING = 32_768
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Обгортає `session.agent.streamFn`, додаючи per-call `maxTokens` у
|
|
147
|
-
* options LLM-виклику. SDK не має публічного per-call параметра
|
|
148
|
-
* (спайк: `session.prompt(text, options)` не прокидає options у loop
|
|
149
|
-
* config), але `options.maxTokens` у streamFn перекриває дефолт моделі —
|
|
150
|
-
* перевірено на дроті через myllm-проксі (`max_completion_tokens`
|
|
151
|
-
* змінюється).
|
|
152
|
-
* @param {object} session сесія з `createAgentSession`
|
|
153
|
-
* @param {number} maxTokens стеля відповіді для всіх викликів цієї сесії
|
|
154
|
-
* @returns {void}
|
|
155
|
-
*/
|
|
156
|
-
function applyMaxTokens(session, maxTokens) {
|
|
157
|
-
const orig = session.agent.streamFn
|
|
158
|
-
session.agent.streamFn = (model, context, options) => orig(model, context, { ...options, maxTokens })
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Sends a single prompt to pi in text mode (no tools) and returns the response.
|
|
163
|
-
* Reads auth/model config from ~/.pi/ same as the CLI.
|
|
164
|
-
* @param {string} prompt текст запиту для моделі
|
|
165
|
-
* @param {object} [opts] додаткові параметри виклику
|
|
166
|
-
* @param {string} [opts.cwd] робоча директорія для session
|
|
167
|
-
* @param {string} [opts.model] provider/model-id для pi (наприклад, "openai/gpt-4o"); без значення — default pi
|
|
168
|
-
* @param {number} [opts.maxTokens] стеля відповіді для цього виклику (перекриває дефолт моделі);
|
|
169
|
-
* на `stopReason: 'length'` (обрізана генерація) виклик повторюється один раз із подвоєною стелею
|
|
170
|
-
* @returns {Promise<string>} текстова відповідь моделі
|
|
171
|
-
*/
|
|
172
|
-
export async function callText(prompt, opts = {}) {
|
|
173
|
-
const result = await withRetry(async () => {
|
|
174
|
-
const cwd = opts.cwd ?? process.cwd()
|
|
175
|
-
const sessionOpts = {
|
|
176
|
-
tools: [],
|
|
177
|
-
sessionManager: SessionManager.inMemory(cwd),
|
|
178
|
-
cwd
|
|
179
|
-
}
|
|
180
|
-
if (opts.model) {
|
|
181
|
-
const registry = await getRegistry()
|
|
182
|
-
const slashIdx = opts.model.indexOf('/')
|
|
183
|
-
const provider = slashIdx === -1 ? null : opts.model.slice(0, slashIdx)
|
|
184
|
-
const modelId = slashIdx === -1 ? opts.model : opts.model.slice(slashIdx + 1)
|
|
185
|
-
const resolved = provider ? Reflect.apply(registry.find, registry, [provider, modelId]) : null
|
|
186
|
-
sessionOpts.modelRegistry = registry
|
|
187
|
-
sessionOpts.model = resolved ?? opts.model
|
|
188
|
-
}
|
|
189
|
-
const { session } = await createAgentSession(sessionOpts)
|
|
190
|
-
if (opts.maxTokens) applyMaxTokens(session, opts.maxTokens)
|
|
191
|
-
try {
|
|
192
|
-
await session.prompt(prompt)
|
|
193
|
-
|
|
194
|
-
const state = session.state
|
|
195
|
-
const last = state.messages.at(-1)
|
|
196
|
-
if (!last || last.role !== 'assistant') return { text: '', truncated: false }
|
|
197
|
-
if (last.stopReason === 'error' || last.stopReason === 'aborted') {
|
|
198
|
-
throw new Error(`pi error: ${last.errorMessage ?? last.stopReason}`)
|
|
199
|
-
}
|
|
200
|
-
return {
|
|
201
|
-
text: last.content
|
|
202
|
-
.filter(c => c.type === 'text')
|
|
203
|
-
.map(c => c.text)
|
|
204
|
-
.join(''),
|
|
205
|
-
truncated: last.stopReason === 'length'
|
|
206
|
-
}
|
|
207
|
-
} finally {
|
|
208
|
-
session.dispose()
|
|
209
|
-
}
|
|
210
|
-
}, prompt)
|
|
211
|
-
|
|
212
|
-
// Обрізана генерація зі зниженою стелею — не палимо retry-цикли колера
|
|
213
|
-
// на «invalid block», а один раз повторюємо з подвоєною стелею.
|
|
214
|
-
if (result.truncated && opts.maxTokens && opts.maxTokens < MAX_TOKENS_CEILING && !opts._lengthRetried) {
|
|
215
|
-
const doubled = Math.min(opts.maxTokens * 2, MAX_TOKENS_CEILING)
|
|
216
|
-
console.log(` ⚠ відповідь обрізана (stopReason: length) — повтор із maxTokens ${opts.maxTokens} → ${doubled}`)
|
|
217
|
-
return callText(prompt, { ...opts, maxTokens: doubled, _lengthRetried: true })
|
|
218
|
-
}
|
|
219
|
-
return result.text
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Sends a prompt to pi in agent mode with full coding tools (read/write/bash/edit).
|
|
224
|
-
* The agent writes test files directly — no need to parse output.
|
|
225
|
-
* @param {string} prompt текст завдання для агента
|
|
226
|
-
* @param {string} cwd робоча директорія, куди агент може писати файли
|
|
227
|
-
* @returns {Promise<void>} проміс завершується після виконання агента
|
|
228
|
-
*/
|
|
229
|
-
export function callAgent(prompt, cwd) {
|
|
230
|
-
return withRetry(async () => {
|
|
231
|
-
const { session } = await createAgentSession({
|
|
232
|
-
tools: ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls'],
|
|
233
|
-
sessionManager: SessionManager.inMemory(cwd),
|
|
234
|
-
cwd
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
try {
|
|
238
|
-
await session.prompt(prompt)
|
|
239
|
-
} finally {
|
|
240
|
-
session.dispose()
|
|
241
|
-
}
|
|
242
|
-
}, prompt)
|
|
243
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Спільний бюджет LLM-промптів (фаза 2 спеки
|
|
3
|
-
* `docs/specs/2026-07-04-omlx-prompt-budget.md`, Д3): єдина точка правди
|
|
4
|
-
* для ліміту символів промпту та стелі відповіді (`maxTokens`) за типом
|
|
5
|
-
* задачі. Захищає від класу проблем «безлімітна секція роздула промпт до
|
|
6
|
-
* memory guard» — кожен колер (gen-tests, fix-tests) бере бюджет звідси
|
|
7
|
-
* замість власних розрізнених констант.
|
|
8
|
-
*
|
|
9
|
-
* Дві незалежні утиліти:
|
|
10
|
-
* - `fitToBudget` — внутрішньопромптове обрізання: дропає/вкорочує
|
|
11
|
-
* низькопріоритетні chunks, поки промпт не влізе в ліміт;
|
|
12
|
-
* - `packBatch` — батчинг цілих одиниць (файлів): скільки влазить зараз,
|
|
13
|
-
* решта — у наступний прохід.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Бюджети за типом задачі: ліміт символів промпту і стеля відповіді. */
|
|
17
|
-
const BUDGETS = {
|
|
18
|
-
header: { maxPromptChars: 8000, maxTokens: 2048 },
|
|
19
|
-
block: { maxPromptChars: 40_000, maxTokens: 8192 },
|
|
20
|
-
'single-file': { maxPromptChars: 60_000, maxTokens: 16_384 },
|
|
21
|
-
fix: { maxPromptChars: 60_000, maxTokens: 16_384 }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Частка голови chunk-а, що лишається при обрізанні (решта — хвіст + маркер). */
|
|
25
|
-
const TRUNCATE_HEAD_RATIO = 0.7
|
|
26
|
-
/** Мінімальний розмір, до якого має сенс обрізати chunk (менше — просто дроп). */
|
|
27
|
-
const MIN_TRUNCATED_CHARS = 400
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Повертає бюджет для типу задачі.
|
|
31
|
-
* @param {'header'|'block'|'single-file'|'fix'} taskKind тип LLM-задачі
|
|
32
|
-
* @returns {{maxPromptChars: number, maxTokens: number}} копія бюджету
|
|
33
|
-
*/
|
|
34
|
-
export function budgetFor(taskKind) {
|
|
35
|
-
const budget = BUDGETS[taskKind]
|
|
36
|
-
if (!budget) throw new Error(`prompt-budget: невідомий taskKind "${taskKind}"`)
|
|
37
|
-
return { ...budget }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Символьно-безпечне обрізання середини: голова + маркер + хвіст.
|
|
42
|
-
* @param {string} text вихідний текст
|
|
43
|
-
* @param {number} maxChars цільовий розмір
|
|
44
|
-
* @returns {string} обрізаний текст із маркером
|
|
45
|
-
*/
|
|
46
|
-
export function capText(text, maxChars) {
|
|
47
|
-
return truncateMiddle(text, maxChars)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Внутрішня реалізація `capText` (окреме ім'я — щоб `fitToBudget` не
|
|
52
|
-
* залежав від публічного контракту).
|
|
53
|
-
* @param {string} text вихідний текст
|
|
54
|
-
* @param {number} maxChars цільовий розмір
|
|
55
|
-
* @returns {string} обрізаний текст із маркером
|
|
56
|
-
*/
|
|
57
|
-
function truncateMiddle(text, maxChars) {
|
|
58
|
-
const chars = [...text]
|
|
59
|
-
if (chars.length <= maxChars) return text
|
|
60
|
-
const head = Math.floor(maxChars * TRUNCATE_HEAD_RATIO)
|
|
61
|
-
const tail = Math.max(0, maxChars - head)
|
|
62
|
-
const dropped = chars.length - head - tail
|
|
63
|
-
return `${chars.slice(0, head).join('')}\n...[обрізано ${dropped} символів]...\n${chars.slice(chars.length - tail).join('')}`
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Вкладає chunks у бюджет: спершу обрізає, потім дропає найнижчі
|
|
68
|
-
* пріоритети, поки сумарний текст не влізе. Chunk із НАЙВИЩИМ
|
|
69
|
-
* пріоритетом (сама задача / останній user-запит) захищений — його
|
|
70
|
-
* текст не ріжеться і не дропається ніколи.
|
|
71
|
-
* @param {Array<{text: string, priority: number, label?: string}>} chunks частини промпту; нижчий priority ріжеться першим
|
|
72
|
-
* @param {number} maxChars бюджет символів на весь результат
|
|
73
|
-
* @returns {{text: string, dropped: string[]}} зібраний промпт + мітки скорочених/викинутих частин
|
|
74
|
-
*/
|
|
75
|
-
export function fitToBudget(chunks, maxChars) {
|
|
76
|
-
const parts = chunks.map((c, i) => ({ ...c, label: c.label ?? `chunk#${i}`, kept: true, out: c.text }))
|
|
77
|
-
const maxPriority = Math.max(...parts.map(p => p.priority))
|
|
78
|
-
const total = () => parts.filter(p => p.kept).reduce((sum, p) => sum + p.out.length + 1, 0)
|
|
79
|
-
const dropped = []
|
|
80
|
-
|
|
81
|
-
const candidates = parts.filter(p => p.priority < maxPriority).toSorted((a, b) => a.priority - b.priority)
|
|
82
|
-
// Прохід 1: обрізати кандидатів (від найнижчого пріоритету)
|
|
83
|
-
for (const part of candidates) {
|
|
84
|
-
if (total() <= maxChars) break
|
|
85
|
-
const overflow = total() - maxChars
|
|
86
|
-
const target = Math.max(MIN_TRUNCATED_CHARS, part.out.length - overflow)
|
|
87
|
-
if (part.out.length > target) {
|
|
88
|
-
part.out = truncateMiddle(part.out, target)
|
|
89
|
-
dropped.push(`${part.label} (обрізано)`)
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
// Прохід 2: дропнути кандидатів цілком, якщо обрізання не вистачило
|
|
93
|
-
for (const part of candidates) {
|
|
94
|
-
if (total() <= maxChars) break
|
|
95
|
-
part.kept = false
|
|
96
|
-
dropped.push(`${part.label} (видалено)`)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
text: parts
|
|
101
|
-
.filter(p => p.kept)
|
|
102
|
-
.map(p => p.out)
|
|
103
|
-
.join('\n'),
|
|
104
|
-
dropped
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Пакує одиниці (файли) у бюджет: найменші першими, щоб максимізувати
|
|
110
|
-
* кількість виправлень за один виклик. Одиниця, що сама-одна перевищує
|
|
111
|
-
* бюджет, потрапляє в `deferred` — колер робить для неї соло-виклик із
|
|
112
|
-
* жорсткішим внутрішнім обрізанням (`fitToBudget`), а не мовчазний skip.
|
|
113
|
-
* @param {Array<{key: string, size: number}>} items одиниці з розмірами
|
|
114
|
-
* @param {number} maxChars бюджет символів на батч
|
|
115
|
-
* @returns {{included: string[], deferred: string[]}} ключі включених і відкладених одиниць
|
|
116
|
-
*/
|
|
117
|
-
export function packBatch(items, maxChars) {
|
|
118
|
-
const sorted = items.toSorted((a, b) => a.size - b.size)
|
|
119
|
-
const included = []
|
|
120
|
-
const deferred = []
|
|
121
|
-
let used = 0
|
|
122
|
-
for (const item of sorted) {
|
|
123
|
-
if (item.size + (included.length === 0 ? 0 : used) <= maxChars) {
|
|
124
|
-
included.push(item.key)
|
|
125
|
-
used += item.size
|
|
126
|
-
} else {
|
|
127
|
-
deferred.push(item.key)
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return { included, deferred }
|
|
131
|
-
}
|