Files
David 2910d06a13 tech(AS-10): zwei Zonen, die gruen aussahen, weil sie niemand geprueft hat
Lint-Schulden ausserhalb von src/components — und der Auftrag hat zwei
blinde Flecken freigelegt, die groesser sind als die Schulden selbst.

`scripts/**` hatte NULL Regeln. Der TypeScript-Block greift *.ts/tsx, der
mobile-client-Block greift mobile-client/**/*.js, und .mjs faellt zwischen
beide. `npx eslint scripts` listete zehn Dateien und meldete nichts — ein
gruener Lauf ueber eine ungepruefte Zone, was schlimmer ist als ein roter:
er behauptet Deckung. Mit Regeln kamen 42 Meldungen zum Vorschein, davon
11 `no-undef` — in einem Node-Modul kein Stil, sondern ein ReferenceError
zur Laufzeit. Alle 42 bezahlt.

`e2e/` stand in KEINEM tsconfig-Projekt. 17 Playwright-Dateien plus
playwright.config.ts hat `npm run typecheck` nie gelesen. Neues
tsconfig.e2e.json, referenziert aus tsconfig.json: neu sichtbare Fehler 0
bei 18 zusaetzlich geprueften Dateien. Die Zone war nicht kaputt, sie war
unbeobachtet.

Ein echter Fehler im ganzen Haufen, und er stand hinter der Konvention:
`builtin-tools.ts:506` faengt `fallbackErr`, wirft ihn weg und meldet `e`,
den Fehler des ERSTEN Weges. Im Browser-/Dev-Modus ist `web_fetch` dort
grundsaetzlich unerreichbar, `e` also auf jedem Fehlschlag derselbe Satz —
DNS, CORS, 404, Timeout sehen fuer das Modell identisch aus. Dreissig
Zeilen hoeher schreibt dieselbe Datei die Regel selbst hin. Mit Test.

Die "10 Parse-Fehler" waren keine: es sind tote eslint-disable-Direktiven,
die eslints JSON mit `ruleId: null` kodiert wie einen Parse-Fehler. Sechs
davon unterdruecken Regeln, die in keiner Config je eingeschaltet waren —
jemand glaubte an ein Gate, das nie existierte. Jetzt `error` statt `warn`.

Regel-Lockerungen, jede an BEIDEN Seiten der Grenze gemessen (der Test
laesst die echte eslint.config.js ueber die Node-API laufen, kein Nachbau):
`no-fallthrough` allowEmptyCase (ein leeres case-Label kann nirgends
hineinfallen — der Kommentar kostet den Fehler, nicht der Code),
`no-unused-vars` mit `^_` (von 43 Meldungen ueberlebt genau EINE, und die
ist der echte Fehler oben), `react-refresh/only-export-components` aus
(23 der 29 Exporte werden von Tests importiert — das ist die Naht, an der
dieses Projekt Logik aus Komponenten zieht; die Alternative waere eine
Namensliste, also genau der zweite Pflegeweg, der hier der Grundfehler ist).

eslint . : 161 Fehler + 10 Warnungen -> 60 Fehler + 0 Warnungen.
Offen fuer das scharfe Gate: 45 in src/components (zweiter Durchgang),
15 in vite.config.ts (ZB-7).

Geprueft im isolierten Worktree auf 70489a23 (nur diese 33 Dateien, und
node_modules diesmal KOPIERT statt verlinkt — Lehre aus dem Zwischenfall):
tsc -b --force 0 (jetzt inkl. e2e) · vite build 0 · madge 0 Zyklen ·
vitest 7851 gruen / 0 rot (= Basis 7809 + 42 aus dem Token-Paket) ·
eslint 60/0, Verteilung nachgezaehlt und deckungsgleich.

Zwei eigene Sonden auf die Kernbehauptung, dass die Zonen jetzt echt
gedeckt sind:
  undefinierter Bezeichner in scripts/  => `no-undef` schlaegt an
  frische tote Unterdrueckung in src/lib => als FEHLER gemeldet, nicht als
                                            Warnung durchgewinkt
2026-09-01 09:14:24 -07:00

345 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Real agent E2E driven through the Remote bridge. Replicates exactly
* what mobile_landing()'s runToolLoop does:
* 1. Auth via /remote-api/auth (passcode → JWT).
* 2. Build messages: system + user.
* 3. POST to /api/chat (proxied to Ollama) with native `tools` array.
* 4. Ollama returns tool_calls; we execute each via /remote-api/agent-tool.
* 5. Push observations back as tool messages, loop until done.
*
* Two modes:
* --mode agent → AGENT_PROMPT (autonomous file-writer)
* --mode codex → CODEX_PROMPT (autonomous coding agent)
*
* Usage:
* node scripts/remote-agent-e2e.mjs --base http://<ip>:<port> --code <6-digit> --mode agent --model gemma4:e4b
*/
import { argv, exit } from 'node:process'
function parseArgs() {
const out = {
base: null, code: null, model: 'gemma4:e4b',
mode: 'agent', chatId: 'agent-e2e',
task: null,
}
for (let i = 2; i < argv.length; i++) {
const a = argv[i]
if (a === '--base') out.base = argv[++i]
else if (a === '--code') out.code = argv[++i]
else if (a === '--model') out.model = argv[++i]
else if (a === '--mode') out.mode = argv[++i]
else if (a === '--chat-id') out.chatId = argv[++i]
else if (a === '--task') out.task = argv[++i]
}
if (!out.base || !out.code) { console.error('Usage: --base <url> --code <6-digit> [--mode agent|codex]'); exit(2) }
if (!['agent', 'codex'].includes(out.mode)) { console.error('--mode must be agent or codex'); exit(2) }
return out
}
// ── Tool definitions — full 13-tool AGENT_TOOLS array from remote.rs::mobile_landing()
// (parity with src/api/mcp/builtin-tools.ts). 10 of these are exposed to Codex too. ──
const AGENT_TOOLS = [
{ name: 'web_search', description: 'Search the web via the configured provider. Returns ranked {title, url, snippet}.',
parameters: [
{ name: 'query', type: 'string', description: 'The search query', required: true },
{ name: 'maxResults', type: 'number', description: 'Max results (default 5)', required: false },
] },
{ name: 'web_fetch', description: 'Fetch a single URL and return readable text up to 24000 chars.',
parameters: [
{ name: 'url', type: 'string', description: 'Full URL with http(s)://', required: true },
] },
{ name: 'file_read', description: 'Read a UTF-8 text file. Returns full contents.',
parameters: [
{ name: 'path', type: 'string', description: 'Path (relative or absolute)', required: true },
] },
{ name: 'file_write', description: 'Write a UTF-8 text file. Creates parent directories automatically. Relative paths resolve to the chat workspace folder.',
parameters: [
{ name: 'path', type: 'string', description: 'Target file path', required: true },
{ name: 'content', type: 'string', description: 'File contents', required: true },
] },
{ name: 'file_list', description: 'List directory contents. Set recursive:true for full tree.',
parameters: [
{ name: 'path', type: 'string', description: 'Directory path', required: true },
{ name: 'recursive', type: 'boolean', description: 'Include all subdirectories', required: false },
] },
{ name: 'file_search', description: 'Regex grep across files in a directory.',
parameters: [
{ name: 'path', type: 'string', description: 'Directory to search', required: true },
{ name: 'query', type: 'string', description: 'Regex pattern', required: true },
] },
{ name: 'shell_execute', description: 'Run a shell command. PowerShell on Windows. cwd defaults to chat workspace.',
parameters: [
{ name: 'command', type: 'string', description: 'Shell command', required: true },
] },
{ name: 'code_execute', description: 'Run a Python script. Runs from the chat workspace.',
parameters: [
{ name: 'code', type: 'string', description: 'Python source code', required: true },
] },
{ name: 'system_info', description: 'Return OS, architecture, hostname, RAM, CPU count.',
parameters: [] },
{ name: 'process_list', description: 'List top 30 running processes by memory.',
parameters: [] },
{ name: 'screenshot', description: 'Capture the primary display as a base64 PNG.',
parameters: [] },
{ name: 'image_generate', description: 'Generate an image from text via local ComfyUI. (Returns graceful error in Remote.)',
parameters: [
{ name: 'prompt', type: 'string', description: 'Positive text description', required: true },
] },
{ name: 'get_current_time', description: 'Return current local date, time, timezone.',
parameters: [] },
]
const CODEX_TOOL_NAMES = ['file_read','file_write','file_list','file_search','shell_execute','code_execute','system_info','get_current_time','web_search','web_fetch']
const AGENT_TOOL_NAMES = AGENT_TOOLS.map(t => t.name)
function buildToolDefs(tools) {
return tools.map(t => {
const props = {}
const req = []
for (const p of t.parameters || []) {
props[p.name] = { type: p.type, description: p.description }
if (p.required) req.push(p.name)
}
return {
type: 'function',
function: { name: t.name, description: t.description,
parameters: { type: 'object', properties: props, required: req } },
}
})
}
const AGENT_PROMPT = 'You are an autonomous AI agent inside Locally Uncensored. You execute tasks end-to-end via tools — you do NOT just describe what to do.\n\nAUTONOMY CONTRACT:\n- When asked to BUILD / CREATE / WRITE something, execute it via tools.\n- NEVER produce a code block followed by "save this as X". Execute it via file_write.\n- NEVER say "Now I will create X" as plain prose and stop. Do the next step right now as a tool call.\n\nFile rules:\n- file_write AUTOMATICALLY creates missing parent directories — do NOT shell out to mkdir / New-Item.\n- Relative paths resolve to the current chat workspace folder. Use relative paths.\n\nBe concise. All the work happens in tool calls.'
const CODEX_PROMPT = 'You are Codex, an autonomous coding agent. Execute coding tasks end-to-end by reading files, writing code, and running shell commands. You MUST use tools.\n\nAUTONOMY CONTRACT:\n- COMPLETE multi-step tasks without prompting between steps.\n- NEVER say "Now I will create X" and stop. Execute the next step.\n- Chain ALL N steps in one session as concrete tool calls.\n\nWorkflow: explore → plan → implement (file_write) — chain ALL writes without stopping → final 1-3 sentence summary.\n\nFile & directory rules:\n- file_write AUTOMATICALLY creates missing parent directories. Never `mkdir` / `New-Item -ItemType Directory`.\n- All relative paths resolve to the current chat workspace.\n- shell_execute runs inside the workspace folder by default.\n- After 2-3 failures of the same approach, switch strategy.\n\nBe concise. Respond in the same language the user uses.'
// Tasks force EVERY tool in the relevant set to be exercised in one run
// ("überall alle testen pflicht ALLE IMMER"). Agent has 13, Codex 10.
const TASK_AGENT = `You are running an agent compliance test. Build a tiny static greeting site inside folder "gemtest" AND prove every Remote tool works.
You MUST call ALL 13 tools below at least once during this run. Do NOT emit a final answer until every tool listed has been used:
1. get_current_time → record start time
2. system_info → capture host facts
3. process_list → snapshot running processes (just call it once)
4. file_write → gemtest/index.html with <h1 id="hero">Hello LU E2E</h1> and <link rel="stylesheet" href="style.css">
5. file_write → gemtest/style.css (body sans-serif on dark bg, #hero = 32px)
6. file_write → gemtest/MARKER.txt containing exactly "MAGIC_LU_AGENT_E2E"
7. file_search → grep gemtest/ for "MAGIC_LU_AGENT_E2E" (must hit MARKER.txt)
8. file_list → list gemtest/ with recursive=true
9. file_read → read gemtest/index.html back
10. code_execute → Python that lists gemtest/ and writes gemtest/manifest.json
11. shell_execute → "Get-ChildItem gemtest" or "ls gemtest" to confirm layout
12. screenshot → call screenshot once (do not embed result, just confirm tool works)
13. web_search → query "locally uncensored github" with maxResults 1
14. web_fetch → fetch "https://example.com" (must succeed)
15. image_generate → prompt "test" (Remote returns a graceful "desktop only" error — that still counts as "tool reachable")
After every required tool was called, write a 1-2 sentence final summary listing which tools you used. DO NOT SKIP ANY TOOL.`
const TASK_CODEX = `You are running a Codex compliance test. Build a Python utility "wordcount.py" in folder "wctest" AND prove every Codex tool works.
You MUST call ALL 10 Codex tools below at least once during this run. Do NOT emit a final answer until every tool below has been used:
1. get_current_time → record start time
2. system_info → capture host facts
3. file_write → wctest/wordcount.py (reads argv[1], prints JSON {"file","lines","words","bytes"})
4. file_write → wctest/sample.txt with exactly 3 lines, including "MAGIC_LU_CODEX_E2E"
5. file_search → grep wctest/ for "MAGIC_LU_CODEX_E2E" (must hit sample.txt)
6. file_list → wctest/ recursive=true
7. file_read → wctest/wordcount.py back
8. shell_execute → "python wctest/wordcount.py wctest/sample.txt"
9. code_execute → Python that writes wctest/runinfo.json with timing + result
10. web_search → query "python json module" with maxResults 1
11. web_fetch → fetch "https://example.com"
After every required tool was called, write a 1-2 sentence final summary listing which tools you used. DO NOT SKIP ANY TOOL.`
async function authenticate(base, code) {
const r = await fetch(`${base}/remote-api/auth`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ passcode: code }),
})
if (!r.ok) throw new Error(`auth ${r.status}: ${await r.text()}`)
return (await r.json()).token
}
async function runTool(base, token, chatId, tool, args) {
const r = await fetch(`${base}/remote-api/agent-tool`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ tool, args, chatId }),
})
return await r.json()
}
function observationToString(data) {
if (!data) return ''
if (typeof data.error === 'string') return 'Error: ' + data.error
if (typeof data.content === 'string') return data.content
if (data.status === 'saved') return 'File saved: ' + (data.path || '')
if (Array.isArray(data.entries)) return JSON.stringify(data.entries.slice(0, 50), null, 2)
if (Array.isArray(data.results)) return JSON.stringify(data.results.slice(0, 20), null, 2)
if (data.exitCode !== undefined) {
if (data.timedOut) return 'Execution timed out.'
if (data.exitCode !== 0) return `Error (${data.exitCode}):\n${data.stderr || data.stdout}`
return data.stdout || 'Done.'
}
if (data.iso || data.iso_local) return JSON.stringify(data)
return JSON.stringify(data)
}
// runToolLoop logic ported from mobile_landing()
function isSystemPromptEcho(content) {
if (!content) return false
const head = String(content).trim().slice(0, 240)
if (/^(hello[!,.]?\s+|hi[!,.]?\s+|hey[!,.]?\s+)?(i['’]?m|i am|you are)\s+(codex|an autonomous|the agent|an? ai)/i.test(head)) return true
if (/^(i am|i['’]m)\s+ready\s+to\s+(receive|assist|help)/i.test(head)) return true
if (/^(hello|hi|hey)[!,.]?\s+i['’]?m\s+ready/i.test(head)) return true
return false
}
function repairToolCallArgs(tc) {
if (!tc?.function) return tc
let a = tc.function.arguments
if (typeof a === 'string') {
// Kein JSON: dann bleibt der String stehen und der Aufrufer sieht ihn
// unveraendert — genau das ist die Reparatur, die diese Funktion anbietet.
try { tc.function.arguments = JSON.parse(a) } catch { /* String bleibt String */ }
}
return tc
}
async function chat(base, token, model, messages, tools) {
const r = await fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ model, messages, tools, stream: false, options: { temperature: 0.2 } }),
})
if (!r.ok) throw new Error(`/api/chat ${r.status}: ${await r.text()}`)
return await r.json()
}
async function runAgentLoop({ base, token, model, chatId, system, task, allowedNames }) {
const filtered = AGENT_TOOLS.filter(t => allowedNames.includes(t.name))
const tools = buildToolDefs(filtered)
const messages = [
{ role: 'system', content: system },
{ role: 'user', content: task },
]
const transcript = []
const usedTools = new Set()
const maxIter = 60 // doubled to give nudges room
let echoRetries = 3
let consecutiveErrors = 0
const maxConsecutiveErrors = 5
let final = null
let nudgesUsed = 0
const maxNudges = 3 // up to 3 follow-up nudges if the model stops early
for (let iter = 1; iter <= maxIter; iter++) {
process.stdout.write(` · iter ${iter}: model thinking...`)
const t0 = Date.now()
const res = await chat(base, token, model, messages, tools)
const ms = Date.now() - t0
process.stdout.write(` (${ms}ms)\n`)
const msg = res.message || {}
let content = msg.content || ''
let toolCalls = (msg.tool_calls || []).map(repairToolCallArgs)
if (isSystemPromptEcho(content)) {
if (echoRetries > 0) {
echoRetries--
console.log(` · echo detected → silent retry (left=${echoRetries})`)
messages.push({ role: 'user', content: 'Continue the task. Do not introduce yourself again. Resume from the last successful step using the appropriate tool call.' })
continue
}
console.log(` · echo retries spent → dropping content`)
content = ''
}
if (!toolCalls.length) {
// Compliance check: if there are still mandated tools that haven't
// been touched, nudge the model to use them before letting it finish.
const missing = allowedNames.filter(n => !usedTools.has(n))
if (missing.length > 0 && nudgesUsed < maxNudges) {
nudgesUsed++
console.log(` · model wants to stop but ${missing.length} tools still missing → nudge ${nudgesUsed}/${maxNudges}: ${missing.join(', ')}`)
// Push the model's would-be final as an assistant message so the
// history stays coherent, then add a user nudge.
messages.push({ role: 'assistant', content: content || '' })
messages.push({ role: 'user', content:
`You stopped before exercising every required tool. Still missing: ${missing.join(', ')}. ` +
`Call each of those tools NOW (one per turn is fine) with a small useful invocation, then write the final summary. ` +
`Tools list reminder: ${allowedNames.join(', ')}.`
})
continue
}
final = content || null
transcript.push({ kind: 'final', content: final })
console.log(` · no toolCalls → finalAnswer (after ${nudgesUsed} nudges)\n "${(final || '(empty → fallback summary will kick in on the UI)').slice(0, 200)}"`)
break
}
messages.push({ role: 'assistant', content, tool_calls: toolCalls.map(tc => ({ function: { name: tc.function.name, arguments: tc.function.arguments } })) })
for (const tc of toolCalls) {
const name = tc.function.name
usedTools.add(name)
const args = tc.function.arguments || {}
const argsPretty = JSON.stringify(args).slice(0, 200)
console.log(` → ${name} ${argsPretty}`)
const data = await runTool(base, token, chatId, name, args)
const obs = observationToString(data)
const isErr = /^(Error|Permission denied|Network error)/i.test(obs)
if (isErr) consecutiveErrors++; else consecutiveErrors = 0
const obsShort = obs.slice(0, 220).replaceAll('\n', ' ')
console.log(` ${isErr ? '✗' : '✓'} ${obsShort}${obs.length > 220 ? '…' : ''}`)
transcript.push({ kind: 'tool', name, args, observation: obs, error: isErr })
messages.push({ role: 'tool', content: obs })
if (consecutiveErrors >= maxConsecutiveErrors) {
console.log(` ! consecutiveErrors=${consecutiveErrors} → bailing`)
final = null
break
}
}
if (consecutiveErrors >= maxConsecutiveErrors) break
}
return { final, transcript, usedTools, consecutiveErrors, echoRetries, nudgesUsed }
}
async function main() {
const { base, code, mode, model, chatId, task } = parseArgs()
const token = await authenticate(base, code)
console.log(`[e2e] Authenticated. Mode=${mode} Model=${model} ChatId=${chatId}`)
const system = mode === 'codex' ? CODEX_PROMPT : AGENT_PROMPT
const userTask = task || (mode === 'codex' ? TASK_CODEX : TASK_AGENT)
console.log(`[e2e] Task:\n ${userTask.replaceAll('\n', '\n ')}\n`)
const allowedNames = mode === 'codex' ? CODEX_TOOL_NAMES : AGENT_TOOL_NAMES
console.log(`[e2e] Tools available: ${allowedNames.length} (${allowedNames.join(', ')})\n`)
const t0 = Date.now()
const result = await runAgentLoop({ base, token, model, chatId, system, task: userTask, allowedNames })
const elapsed = Date.now() - t0
const missing = allowedNames.filter(n => !result.usedTools.has(n))
console.log(`\n━━ Run summary ━━`)
console.log(`Mode: ${mode}`)
console.log(`Tools available: ${allowedNames.length}`)
console.log(`Tools exercised (${result.usedTools.size}): ${[...result.usedTools].sort().join(', ')}`)
console.log(`Tools NOT exercised (${missing.length}): ${missing.length ? missing.join(', ') : '(none — all tools hit ✓)'}`)
console.log(`Tool turns recorded: ${result.transcript.filter(t => t.kind === 'tool').length}`)
console.log(`Errors during run: ${result.transcript.filter(t => t.kind === 'tool' && t.error).length}`)
console.log(`Echo retries used: ${3 - result.echoRetries}`)
console.log(`Consecutive-err count: ${result.consecutiveErrors}`)
console.log(`Tool-coverage nudges used: ${result.nudgesUsed}`)
console.log(`Total wall time: ${elapsed} ms`)
const hasGreeting = result.final && /^(hello|hi|hey).{0,40}(codex|the agent|an ai)/i.test(result.final)
console.log(`Final answer was a greeting: ${hasGreeting ? 'YES (BUG)' : 'NO ✓'}`)
console.log(`Final answer:\n "${(result.final || '(no final, fallback would kick in)').slice(0, 400)}"`)
exit(hasGreeting || missing.length > 0 ? 1 : 0)
}
main().catch(e => { console.error('[e2e] fatal:', e); exit(2) })