nothumanallowed 15.0.19 → 15.1.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/package.json +1 -1
- package/src/server/routes/webcraft.mjs +185 -58
- package/src/services/llm.mjs +282 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "15.0
|
|
3
|
+
"version": "15.1.0",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@ import { createServer } from 'net';
|
|
|
19
19
|
import { promisify } from 'util';
|
|
20
20
|
import { sendJSON, sendError, parseBody, sendSSE } from '../index.mjs';
|
|
21
21
|
import { loadConfig } from '../../config.mjs';
|
|
22
|
-
import { callLLM, callLLMStream, fixQwen3BPE } from '../../services/llm.mjs';
|
|
22
|
+
import { callLLM, callLLMStream, callLLMWithTools, getApiKey, fixQwen3BPE } from '../../services/llm.mjs';
|
|
23
23
|
import { NHA_DIR } from '../../constants.mjs';
|
|
24
24
|
import * as acorn from 'acorn';
|
|
25
25
|
import acornJsx from 'acorn-jsx';
|
|
@@ -616,41 +616,31 @@ async function runWebCraftAgent(config, projectName, message, attachments, emit,
|
|
|
616
616
|
const LANG_MAP = { en:'English',it:'Italian',es:'Spanish',fr:'French',de:'German',pt:'Portuguese' };
|
|
617
617
|
const language = LANG_MAP[(config?.language||'it').slice(0,2)] || 'Italian';
|
|
618
618
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
⚠ CRITICAL RULES:
|
|
646
|
-
- To modify an existing file: ALWAYS use edit. NEVER use write on existing files — it will be REJECTED.
|
|
647
|
-
- edit workflow: read the file first → copy EXACT lines from the read output → use edit with old/new
|
|
648
|
-
- To APPEND content to a truncated file: use edit where "old" is the last few lines of the file, and "new" is those same lines PLUS the new content after them.
|
|
649
|
-
- Keep each edit SMALL — max 30-40 lines of old/new. If you need to add more, do MULTIPLE edits.
|
|
650
|
-
- If edit returns "old_not_found": your old text didn't match. Read the file again and retry with the EXACT text.
|
|
651
|
-
- NEVER say you fixed something without verifying — use check or read after edit to confirm.
|
|
652
|
-
- write is ONLY for creating brand new files
|
|
653
|
-
- Output <done/> when finished
|
|
619
|
+
// Native tool definitions for function calling (Anthropic/OpenAI)
|
|
620
|
+
const nativeTools = [
|
|
621
|
+
{ name: 'read_file', description: 'Read a file from the project', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path to the file' } }, required: ['path'] } },
|
|
622
|
+
{ name: 'edit_file', description: 'Make a surgical edit to an existing file. The old_text must be an EXACT match of the current file content. Copy-paste from read_file output.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path' }, old_text: { type: 'string', description: 'Exact text to replace (copy from read_file output)' }, new_text: { type: 'string', description: 'Replacement text' } }, required: ['path', 'old_text', 'new_text'] } },
|
|
623
|
+
{ name: 'create_file', description: 'Create a new file. ONLY for files that do not exist yet. Cannot overwrite existing files.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path' }, content: { type: 'string', description: 'Full file content' } }, required: ['path', 'content'] } },
|
|
624
|
+
{ name: 'delete_file', description: 'Delete a file', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path' } }, required: ['path'] } },
|
|
625
|
+
{ name: 'list_files', description: 'List all project files', input_schema: { type: 'object', properties: {} } },
|
|
626
|
+
{ name: 'search_files', description: 'Search for text in project files', input_schema: { type: 'object', properties: { query: { type: 'string', description: 'Search pattern' }, glob: { type: 'string', description: 'File glob pattern (e.g. *.js)' } }, required: ['query'] } },
|
|
627
|
+
{ name: 'run_command', description: 'Run a shell command in the project directory', input_schema: { type: 'object', properties: { command: { type: 'string', description: 'Shell command' } }, required: ['command'] } },
|
|
628
|
+
{ name: 'check_syntax', description: 'Check file for syntax errors', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path' } }, required: ['path'] } },
|
|
629
|
+
{ name: 'restart_sandbox', description: 'Restart the sandbox server to test changes', input_schema: { type: 'object', properties: {} } },
|
|
630
|
+
];
|
|
631
|
+
|
|
632
|
+
// System prompt for native tool calling (no <tool> tags needed)
|
|
633
|
+
const toolInstructions = `
|
|
634
|
+
WORKFLOW: read_file → edit_file (surgical changes) → check_syntax → restart_sandbox
|
|
635
|
+
|
|
636
|
+
RULES:
|
|
637
|
+
- To modify existing files: ALWAYS use edit_file. NEVER use create_file on existing files — it will fail.
|
|
638
|
+
- edit_file: read the file first, then copy EXACT text from the read output as old_text.
|
|
639
|
+
- To APPEND to a truncated file: old_text = last few lines, new_text = those lines + new content.
|
|
640
|
+
- Keep each edit SMALL — max 30-40 lines. Do MULTIPLE edits for larger changes.
|
|
641
|
+
- If edit_file fails (old_text not found): read the file again, copy the EXACT text, retry.
|
|
642
|
+
- NEVER say you fixed something without verifying with check_syntax.
|
|
643
|
+
- create_file is ONLY for brand new files.
|
|
654
644
|
`;
|
|
655
645
|
|
|
656
646
|
// Build system prompt with fresh file list each step
|
|
@@ -685,16 +675,14 @@ WORKFLOW:
|
|
|
685
675
|
return [
|
|
686
676
|
`You are WebCraft Agent — an elite AI coding assistant. Today: ${today}. Language: ${language}.`,
|
|
687
677
|
`\nYou control the project IDE. You MUST use tools to implement changes — never just explain.`,
|
|
688
|
-
`\nYour workflow: read → plan → edit/write → check → verify → done.`,
|
|
689
678
|
`\nAfter EVERY tool use, you will receive the result. Based on the result, decide what to do next.`,
|
|
690
|
-
`\nWhen finished, output <done/> to signal completion.`,
|
|
691
679
|
`\n\n## PROJECT: ${projectName}`,
|
|
692
680
|
`\n## FILE TREE:\n${fileIndex}`,
|
|
693
681
|
skillContext,
|
|
694
682
|
skillCtx ? `\n\n## PROJECT KNOWLEDGE:\n${skillCtx}` : '',
|
|
695
683
|
attachments?.length ? `\n\n## ATTACHMENTS: ${attachments.map((a) => a.name).join(', ')}` : '',
|
|
696
684
|
fileContents ? `\n\n## LOADED FILES:\n${fileContents}` : '',
|
|
697
|
-
`\n\n${
|
|
685
|
+
`\n\n${toolInstructions}`,
|
|
698
686
|
].join('');
|
|
699
687
|
}
|
|
700
688
|
|
|
@@ -703,17 +691,164 @@ WORKFLOW:
|
|
|
703
691
|
? _buildMultimodalContent(message, attachments)
|
|
704
692
|
: message;
|
|
705
693
|
|
|
706
|
-
// ── Agentic loop
|
|
694
|
+
// ── Agentic loop — native tool calling ────────────────────────────────────
|
|
707
695
|
let hasChanges = false;
|
|
708
696
|
const modifiedFiles = new Set();
|
|
709
|
-
let conversationHistory = [
|
|
710
|
-
{ role: 'user', content: userContent },
|
|
711
|
-
];
|
|
712
697
|
|
|
713
|
-
|
|
714
|
-
|
|
698
|
+
// Tool execution handler — called by callLLMWithTools for each tool_use
|
|
699
|
+
async function handleToolCall(toolName, input) {
|
|
700
|
+
const relPath = input.path;
|
|
701
|
+
if (relPath && !_isSafePath(relPath)) return 'Error: unsafe path';
|
|
702
|
+
|
|
703
|
+
if (toolName === 'read_file') {
|
|
704
|
+
const src = ProjectStore.readFile(projectName, relPath);
|
|
705
|
+
emit({ type: 'tool', op: 'read', path: relPath, result: src ? 'ok' : 'not_found' });
|
|
706
|
+
return src !== null ? src.slice(0, 16000) : 'Error: file not found';
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (toolName === 'edit_file') {
|
|
710
|
+
const src = ProjectStore.readFile(projectName, relPath);
|
|
711
|
+
if (!src) { emit({ type: 'tool', op: 'edit', path: relPath, result: 'file_not_found' }); return 'Error: file not found'; }
|
|
712
|
+
const oldStr = input.old_text;
|
|
713
|
+
const newStr = input.new_text;
|
|
714
|
+
if (src.includes(oldStr)) {
|
|
715
|
+
const newSrc = src.replace(oldStr, newStr ?? '');
|
|
716
|
+
ProjectStore.writeFile(projectName, relPath, newSrc);
|
|
717
|
+
hasChanges = true;
|
|
718
|
+
modifiedFiles.add(relPath);
|
|
719
|
+
emit({ type: 'tool', op: 'edit', path: relPath, result: 'ok', oldSnippet: oldStr.slice(0, 2000), newSnippet: (newStr ?? '').slice(0, 2000) });
|
|
720
|
+
return 'OK — edit applied successfully';
|
|
721
|
+
}
|
|
722
|
+
// Fuzzy match
|
|
723
|
+
const oldLines = oldStr.split('\n').map(l => l.trim());
|
|
724
|
+
const srcLines = src.split('\n');
|
|
725
|
+
let matchStart = -1;
|
|
726
|
+
for (let i = 0; i <= srcLines.length - oldLines.length; i++) {
|
|
727
|
+
let ok = true;
|
|
728
|
+
for (let j = 0; j < oldLines.length; j++) {
|
|
729
|
+
if (srcLines[i + j].trim() !== oldLines[j]) { ok = false; break; }
|
|
730
|
+
}
|
|
731
|
+
if (ok) { matchStart = i; break; }
|
|
732
|
+
}
|
|
733
|
+
if (matchStart >= 0) {
|
|
734
|
+
const before = srcLines.slice(0, matchStart).join('\n');
|
|
735
|
+
const after = srcLines.slice(matchStart + oldLines.length).join('\n');
|
|
736
|
+
const result = (before ? before + '\n' : '') + (newStr ?? '') + (after ? '\n' + after : '');
|
|
737
|
+
ProjectStore.writeFile(projectName, relPath, result);
|
|
738
|
+
hasChanges = true;
|
|
739
|
+
modifiedFiles.add(relPath);
|
|
740
|
+
emit({ type: 'tool', op: 'edit', path: relPath, result: 'ok', oldSnippet: oldStr.slice(0, 2000), newSnippet: (newStr ?? '').slice(0, 2000) });
|
|
741
|
+
return 'OK — edit applied (fuzzy match)';
|
|
742
|
+
}
|
|
743
|
+
emit({ type: 'tool', op: 'edit', path: relPath, result: 'old_not_found' });
|
|
744
|
+
return 'Error: old_text not found in file. Use read_file to see the EXACT current content, copy the exact lines, and retry.';
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (toolName === 'create_file') {
|
|
748
|
+
const existing = ProjectStore.readFile(projectName, relPath);
|
|
749
|
+
if (existing !== null) {
|
|
750
|
+
emit({ type: 'tool', op: 'write', path: relPath, result: 'blocked_use_edit' });
|
|
751
|
+
return 'Error: file already exists. Use edit_file to modify it.';
|
|
752
|
+
}
|
|
753
|
+
ProjectStore.writeFile(projectName, relPath, input.content ?? '');
|
|
754
|
+
hasChanges = true;
|
|
755
|
+
modifiedFiles.add(relPath);
|
|
756
|
+
emit({ type: 'tool', op: 'write', path: relPath, result: 'ok', newSnippet: (input.content ?? '').slice(0, 500) });
|
|
757
|
+
return 'OK — file created';
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (toolName === 'delete_file') {
|
|
761
|
+
const abs = path.join(dir, relPath);
|
|
762
|
+
if (fs.existsSync(abs)) { fs.unlinkSync(abs); hasChanges = true; modifiedFiles.add(relPath); }
|
|
763
|
+
emit({ type: 'tool', op: 'delete', path: relPath, result: 'ok' });
|
|
764
|
+
return 'OK — file deleted';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (toolName === 'list_files') {
|
|
768
|
+
const files = _listProjectFiles(dir);
|
|
769
|
+
emit({ type: 'tool', op: 'list', path: '', result: `${files.length} files` });
|
|
770
|
+
return files.map(f => `- ${f}`).join('\n');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (toolName === 'search_files') {
|
|
774
|
+
const matches = ProjectStore.grep(projectName, input.query);
|
|
775
|
+
emit({ type: 'tool', op: 'search', path: '', result: `${matches.length} matches` });
|
|
776
|
+
return matches.length === 0 ? 'No matches found' : matches.slice(0, 20).map(m => `${m.file}:${m.lineNum}: ${m.line}`).join('\n');
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (toolName === 'run_command') {
|
|
780
|
+
const blocked = /rm\s+-rf|rmdir|format|mkfs|dd\s+if|shutdown|reboot|kill\s+-9\s+1\b/i;
|
|
781
|
+
if (blocked.test(input.command)) { emit({ type: 'tool', op: 'run', path: '', result: 'blocked' }); return 'Error: dangerous command blocked'; }
|
|
782
|
+
try {
|
|
783
|
+
const { stdout, stderr } = await execAsync(input.command, { cwd: dir, timeout: 30000, env: { ...process.env, NODE_ENV: 'development' } });
|
|
784
|
+
emit({ type: 'tool', op: 'run', path: '', result: 'ok' });
|
|
785
|
+
return (stdout + (stderr ? '\n[stderr] ' + stderr : '')).slice(0, 4000) || 'OK — no output';
|
|
786
|
+
} catch (e) {
|
|
787
|
+
emit({ type: 'tool', op: 'run', path: '', result: 'error' });
|
|
788
|
+
return `Error: ${(e.stderr || e.message || '').slice(0, 2000)}`;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (toolName === 'check_syntax') {
|
|
793
|
+
const src = ProjectStore.readFile(projectName, relPath);
|
|
794
|
+
if (!src) { emit({ type: 'tool', op: 'check', path: relPath, result: 'not_found' }); return 'File not found'; }
|
|
795
|
+
const ext = relPath.split('.').pop()?.toLowerCase();
|
|
796
|
+
let result = 'ok';
|
|
797
|
+
if (ext === 'js' || ext === 'mjs') { try { new Function(src); } catch (e) { result = `syntax_error: ${e.message.replace(/\n.*/s, '')}`; } }
|
|
798
|
+
else if (ext === 'json') { try { JSON.parse(src); } catch (e) { result = `json_error: ${e.message}`; } }
|
|
799
|
+
else if (ext === 'html') { result = src.includes('</html>') ? 'ok' : 'missing </html> tag'; }
|
|
800
|
+
emit({ type: 'tool', op: 'check', path: relPath, result });
|
|
801
|
+
return result === 'ok' ? 'OK — no syntax errors' : result;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (toolName === 'restart_sandbox') {
|
|
805
|
+
if (sandbox.isRunning()) await sandbox.stop();
|
|
806
|
+
try {
|
|
807
|
+
const port = await _findFreePort(4000, 4999);
|
|
808
|
+
if (port) {
|
|
809
|
+
const shimDir = path.join(dir, '.nha-shims');
|
|
810
|
+
ensureDir(shimDir);
|
|
811
|
+
_writeShims(shimDir);
|
|
812
|
+
const entryFile = _detectEntry(dir);
|
|
813
|
+
if (entryFile) {
|
|
814
|
+
const patchedEntry = _patchEntry(dir, entryFile, shimDir, port);
|
|
815
|
+
const proc = spawn('node', [patchedEntry], { cwd: dir, env: { ...process.env, PORT: String(port), NODE_ENV: 'development', NHA_SANDBOX: '1' }, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
816
|
+
sandbox._sandbox = { proc, port, projectName, startedAt: new Date(), healthy: false };
|
|
817
|
+
const healthy = await _waitForPort(port, 10000);
|
|
818
|
+
if (healthy) { sandbox._sandbox.healthy = true; emit({ type: 'sandbox_ready', port }); return `OK — sandbox running on port ${port}`; }
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
} catch {}
|
|
822
|
+
emit({ type: 'tool', op: 'sandbox', path: '', result: 'error' });
|
|
823
|
+
return 'Error: sandbox failed to start';
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return `Error: unknown tool ${toolName}`;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Try native tool calling first (Anthropic, OpenAI)
|
|
830
|
+
const provider = config.llm?.provider || 'anthropic';
|
|
831
|
+
const useNativeTools = provider === 'anthropic' || provider === 'openai';
|
|
832
|
+
|
|
833
|
+
if (useNativeTools) {
|
|
715
834
|
const systemPrompt = buildSystemPrompt();
|
|
716
|
-
|
|
835
|
+
const messages = [{ role: 'user', content: userContent }];
|
|
836
|
+
|
|
837
|
+
await callLLMWithTools(config, systemPrompt, messages, nativeTools,
|
|
838
|
+
(text) => emit({ type: 'text', token: text }),
|
|
839
|
+
handleToolCall,
|
|
840
|
+
{ max_tokens: 16384, isAborted, maxTurns: MAX_STEPS }
|
|
841
|
+
);
|
|
842
|
+
} else {
|
|
843
|
+
// Fallback for other providers — use old text-based <tool> system
|
|
844
|
+
// (kept for backward compatibility with Gemini, DeepSeek, etc.)
|
|
845
|
+
let conversationHistory = [{ role: 'user', content: userContent }];
|
|
846
|
+
emit({ type: 'text', token: 'Note: Using text-based tools (native tool calling not available for this provider).\n' });
|
|
847
|
+
// Minimal old-style loop with <tool> tags — simplified
|
|
848
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
849
|
+
if (isAborted()) break;
|
|
850
|
+
const systemPrompt = buildSystemPrompt();
|
|
851
|
+
emit({ type: 'step', step: step + 1, max: MAX_STEPS });
|
|
717
852
|
|
|
718
853
|
// Build user message from conversation history
|
|
719
854
|
// callLLMStream takes a single string, so we concatenate the conversation
|
|
@@ -867,18 +1002,9 @@ WORKFLOW:
|
|
|
867
1002
|
toolResults.push({ op: 'edit', path: relPath, result: 'ok' });
|
|
868
1003
|
emit({ type: 'tool', op: 'edit', path: relPath, result: 'ok', oldSnippet: oldStr.slice(0, 2000), newSnippet: newStr?.slice(0, 2000) });
|
|
869
1004
|
} else {
|
|
870
|
-
// No
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
ProjectStore.writeFile(projectName, relPath, repaired);
|
|
874
|
-
hasChanges = true;
|
|
875
|
-
modifiedFiles.add(relPath);
|
|
876
|
-
toolResults.push({ op: 'edit', path: relPath, result: 'ok_repaired' });
|
|
877
|
-
emit({ type: 'tool', op: 'edit', path: relPath, result: 'ok_repaired', oldSnippet: oldStr.slice(0, 2000), newSnippet: newStr?.slice(0, 2000) });
|
|
878
|
-
} else {
|
|
879
|
-
toolResults.push({ op: 'edit', path: relPath, result: 'old_not_found — read the file first, copy EXACT text to replace, then retry' });
|
|
880
|
-
emit({ type: 'tool', op: 'edit', path: relPath, result: 'old_not_found', oldSnippet: oldStr.slice(0, 200) });
|
|
881
|
-
}
|
|
1005
|
+
// No match — return error, let the agent retry with correct text
|
|
1006
|
+
toolResults.push({ op: 'edit', path: relPath, result: 'old_not_found — your old text does not match the file. Use read tool to see the EXACT current content, then copy-paste the exact lines and retry.' });
|
|
1007
|
+
emit({ type: 'tool', op: 'edit', path: relPath, result: 'old_not_found', oldSnippet: oldStr.slice(0, 200) });
|
|
882
1008
|
}
|
|
883
1009
|
}
|
|
884
1010
|
|
|
@@ -1156,6 +1282,7 @@ WORKFLOW:
|
|
|
1156
1282
|
conversationHistory = [conversationHistory[0], ...conversationHistory.slice(-6)];
|
|
1157
1283
|
}
|
|
1158
1284
|
}
|
|
1285
|
+
} // end of fallback else block
|
|
1159
1286
|
|
|
1160
1287
|
// ── Post-edit: syntax check all modified JS files ──────────────────────────
|
|
1161
1288
|
const syntaxErrors = [];
|
package/src/services/llm.mjs
CHANGED
|
@@ -713,6 +713,288 @@ export function getProviderCall(provider) {
|
|
|
713
713
|
return PROVIDERS[provider] || null;
|
|
714
714
|
}
|
|
715
715
|
|
|
716
|
+
/**
|
|
717
|
+
* Call LLM with native tool_use (function calling) — supports Anthropic and OpenAI.
|
|
718
|
+
* Handles the full agentic loop: call → tool_use → tool_result → call → ... → end_turn.
|
|
719
|
+
*
|
|
720
|
+
* @param {object} config — NHA config with llm.provider, llm.apiKey, etc.
|
|
721
|
+
* @param {string} systemPrompt
|
|
722
|
+
* @param {Array} messages — conversation history [{role, content}]
|
|
723
|
+
* @param {Array} tools — tool definitions [{name, description, input_schema}]
|
|
724
|
+
* @param {Function} onText — callback for text tokens (text: string) => void
|
|
725
|
+
* @param {Function} onToolCall — callback when tool is called (name: string, input: object) => Promise<string> — must return the tool result
|
|
726
|
+
* @param {object} opts — { max_tokens, isAborted, maxTurns }
|
|
727
|
+
* @returns {Promise<{messages: Array, stopReason: string}>} — updated messages array
|
|
728
|
+
*/
|
|
729
|
+
export async function callLLMWithTools(config, systemPrompt, messages, tools, onText, onToolCall, opts = {}) {
|
|
730
|
+
const provider = config.llm?.provider || 'anthropic';
|
|
731
|
+
const model = config.llm?.model || null;
|
|
732
|
+
const apiKey = getApiKey(config, provider);
|
|
733
|
+
if (!apiKey) throw new Error(`No API key for ${provider}`);
|
|
734
|
+
|
|
735
|
+
// Anthropic native tool calling
|
|
736
|
+
if (provider === 'anthropic') {
|
|
737
|
+
return _callAnthropicWithTools(apiKey, model, systemPrompt, messages, tools, onText, onToolCall, opts);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// OpenAI native function calling
|
|
741
|
+
if (provider === 'openai') {
|
|
742
|
+
return _callOpenAIWithTools(apiKey, model, systemPrompt, messages, tools, onText, onToolCall, opts);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Other providers: fallback to text-based tool calling (old system)
|
|
746
|
+
// The caller should handle this by checking the return value
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function _callAnthropicWithTools(apiKey, model, systemPrompt, messages, tools, onText, onToolCall, opts = {}) {
|
|
751
|
+
const maxTokens = opts.max_tokens || 16384;
|
|
752
|
+
const isAborted = opts.isAborted || (() => false);
|
|
753
|
+
const MAX_TURNS = opts.maxTurns || 8;
|
|
754
|
+
|
|
755
|
+
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
756
|
+
if (isAborted()) break;
|
|
757
|
+
|
|
758
|
+
const body = {
|
|
759
|
+
model: model || 'claude-sonnet-4-20250514',
|
|
760
|
+
max_tokens: maxTokens,
|
|
761
|
+
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
|
|
762
|
+
messages,
|
|
763
|
+
tools,
|
|
764
|
+
stream: true,
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
768
|
+
method: 'POST',
|
|
769
|
+
headers: {
|
|
770
|
+
'Content-Type': 'application/json',
|
|
771
|
+
'x-api-key': apiKey,
|
|
772
|
+
'anthropic-version': '2023-06-01',
|
|
773
|
+
'anthropic-beta': 'prompt-caching-2024-07-31',
|
|
774
|
+
},
|
|
775
|
+
body: JSON.stringify(body),
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
if (!res.ok) {
|
|
779
|
+
const err = await res.text();
|
|
780
|
+
throw new Error(`Anthropic ${res.status}: ${err}`);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Parse streaming response
|
|
784
|
+
const reader = res.body.getReader();
|
|
785
|
+
const dec = new TextDecoder();
|
|
786
|
+
let buf = '';
|
|
787
|
+
let assistantContent = [];
|
|
788
|
+
let currentText = '';
|
|
789
|
+
let currentToolUse = null;
|
|
790
|
+
let currentToolInput = '';
|
|
791
|
+
let stopReason = null;
|
|
792
|
+
|
|
793
|
+
while (true) {
|
|
794
|
+
if (isAborted()) { try { reader.cancel(); } catch {} break; }
|
|
795
|
+
const { done, value } = await reader.read();
|
|
796
|
+
if (done) break;
|
|
797
|
+
buf += dec.decode(value, { stream: true });
|
|
798
|
+
const lines = buf.split('\n');
|
|
799
|
+
buf = lines.pop() || '';
|
|
800
|
+
|
|
801
|
+
for (const line of lines) {
|
|
802
|
+
if (!line.startsWith('data: ')) continue;
|
|
803
|
+
const data = line.slice(6).trim();
|
|
804
|
+
if (data === '[DONE]') continue;
|
|
805
|
+
try {
|
|
806
|
+
const ev = JSON.parse(data);
|
|
807
|
+
if (ev.type === 'content_block_start') {
|
|
808
|
+
if (ev.content_block?.type === 'text') {
|
|
809
|
+
currentText = '';
|
|
810
|
+
} else if (ev.content_block?.type === 'tool_use') {
|
|
811
|
+
currentToolUse = { id: ev.content_block.id, name: ev.content_block.name };
|
|
812
|
+
currentToolInput = '';
|
|
813
|
+
}
|
|
814
|
+
} else if (ev.type === 'content_block_delta') {
|
|
815
|
+
if (ev.delta?.type === 'text_delta') {
|
|
816
|
+
currentText += ev.delta.text;
|
|
817
|
+
if (onText) onText(ev.delta.text);
|
|
818
|
+
} else if (ev.delta?.type === 'input_json_delta') {
|
|
819
|
+
currentToolInput += ev.delta.partial_json;
|
|
820
|
+
}
|
|
821
|
+
} else if (ev.type === 'content_block_stop') {
|
|
822
|
+
if (currentText) {
|
|
823
|
+
assistantContent.push({ type: 'text', text: currentText });
|
|
824
|
+
currentText = '';
|
|
825
|
+
}
|
|
826
|
+
if (currentToolUse) {
|
|
827
|
+
let input = {};
|
|
828
|
+
try { input = JSON.parse(currentToolInput); } catch {}
|
|
829
|
+
assistantContent.push({
|
|
830
|
+
type: 'tool_use',
|
|
831
|
+
id: currentToolUse.id,
|
|
832
|
+
name: currentToolUse.name,
|
|
833
|
+
input,
|
|
834
|
+
});
|
|
835
|
+
currentToolUse = null;
|
|
836
|
+
currentToolInput = '';
|
|
837
|
+
}
|
|
838
|
+
} else if (ev.type === 'message_delta') {
|
|
839
|
+
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
|
|
840
|
+
}
|
|
841
|
+
} catch {}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Add assistant message to conversation
|
|
846
|
+
messages.push({ role: 'assistant', content: assistantContent });
|
|
847
|
+
|
|
848
|
+
// If stop reason is end_turn (no more tool calls), we're done
|
|
849
|
+
if (stopReason === 'end_turn' || stopReason !== 'tool_use') break;
|
|
850
|
+
|
|
851
|
+
// Execute tool calls
|
|
852
|
+
const toolUseBlocks = assistantContent.filter(b => b.type === 'tool_use');
|
|
853
|
+
if (toolUseBlocks.length === 0) break;
|
|
854
|
+
|
|
855
|
+
const toolResults = [];
|
|
856
|
+
for (const toolBlock of toolUseBlocks) {
|
|
857
|
+
if (isAborted()) break;
|
|
858
|
+
const result = await onToolCall(toolBlock.name, toolBlock.input);
|
|
859
|
+
toolResults.push({
|
|
860
|
+
type: 'tool_result',
|
|
861
|
+
tool_use_id: toolBlock.id,
|
|
862
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Add tool results to conversation
|
|
867
|
+
messages.push({ role: 'user', content: toolResults });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { messages, stopReason: 'end' };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function _callOpenAIWithTools(apiKey, model, systemPrompt, messages, tools, onText, onToolCall, opts = {}) {
|
|
874
|
+
const maxTokens = opts.max_tokens || 16384;
|
|
875
|
+
const isAborted = opts.isAborted || (() => false);
|
|
876
|
+
const MAX_TURNS = opts.maxTurns || 8;
|
|
877
|
+
|
|
878
|
+
// Convert Anthropic tool format to OpenAI function format
|
|
879
|
+
const openaiTools = tools.map(t => ({
|
|
880
|
+
type: 'function',
|
|
881
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
882
|
+
}));
|
|
883
|
+
|
|
884
|
+
// Convert messages: Anthropic uses content arrays, OpenAI uses strings
|
|
885
|
+
const toOpenAI = (msgs) => {
|
|
886
|
+
const result = [{ role: 'system', content: systemPrompt }];
|
|
887
|
+
for (const m of msgs) {
|
|
888
|
+
if (m.role === 'user') {
|
|
889
|
+
if (Array.isArray(m.content)) {
|
|
890
|
+
// tool_result blocks
|
|
891
|
+
for (const block of m.content) {
|
|
892
|
+
if (block.type === 'tool_result') {
|
|
893
|
+
result.push({ role: 'tool', tool_call_id: block.tool_use_id, content: block.content || '' });
|
|
894
|
+
} else {
|
|
895
|
+
result.push({ role: 'user', content: typeof block === 'string' ? block : block.text || JSON.stringify(block) });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
result.push({ role: 'user', content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) });
|
|
900
|
+
}
|
|
901
|
+
} else if (m.role === 'assistant') {
|
|
902
|
+
if (Array.isArray(m.content)) {
|
|
903
|
+
const textParts = m.content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
904
|
+
const toolCalls = m.content.filter(b => b.type === 'tool_use').map(b => ({
|
|
905
|
+
id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input) },
|
|
906
|
+
}));
|
|
907
|
+
const msg = { role: 'assistant', content: textParts || null };
|
|
908
|
+
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
|
909
|
+
result.push(msg);
|
|
910
|
+
} else {
|
|
911
|
+
result.push({ role: 'assistant', content: typeof m.content === 'string' ? m.content : '' });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return result;
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
919
|
+
if (isAborted()) break;
|
|
920
|
+
|
|
921
|
+
const body = {
|
|
922
|
+
model: model || 'gpt-4o',
|
|
923
|
+
max_tokens: maxTokens,
|
|
924
|
+
messages: toOpenAI(messages),
|
|
925
|
+
tools: openaiTools,
|
|
926
|
+
stream: true,
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
930
|
+
method: 'POST',
|
|
931
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
932
|
+
body: JSON.stringify(body),
|
|
933
|
+
});
|
|
934
|
+
if (!res.ok) { const err = await res.text(); throw new Error(`OpenAI ${res.status}: ${err}`); }
|
|
935
|
+
|
|
936
|
+
const reader = res.body.getReader();
|
|
937
|
+
const dec = new TextDecoder();
|
|
938
|
+
let buf = '';
|
|
939
|
+
let assistantContent = [];
|
|
940
|
+
let currentText = '';
|
|
941
|
+
let toolCalls = {};
|
|
942
|
+
let finishReason = null;
|
|
943
|
+
|
|
944
|
+
while (true) {
|
|
945
|
+
if (isAborted()) { try { reader.cancel(); } catch {} break; }
|
|
946
|
+
const { done, value } = await reader.read();
|
|
947
|
+
if (done) break;
|
|
948
|
+
buf += dec.decode(value, { stream: true });
|
|
949
|
+
const lines = buf.split('\n');
|
|
950
|
+
buf = lines.pop() || '';
|
|
951
|
+
|
|
952
|
+
for (const line of lines) {
|
|
953
|
+
if (!line.startsWith('data: ')) continue;
|
|
954
|
+
const data = line.slice(6).trim();
|
|
955
|
+
if (data === '[DONE]') continue;
|
|
956
|
+
try {
|
|
957
|
+
const ev = JSON.parse(data);
|
|
958
|
+
const delta = ev.choices?.[0]?.delta;
|
|
959
|
+
if (delta?.content) { currentText += delta.content; if (onText) onText(delta.content); }
|
|
960
|
+
if (delta?.tool_calls) {
|
|
961
|
+
for (const tc of delta.tool_calls) {
|
|
962
|
+
if (!toolCalls[tc.index]) toolCalls[tc.index] = { id: tc.id || '', name: '', args: '' };
|
|
963
|
+
if (tc.id) toolCalls[tc.index].id = tc.id;
|
|
964
|
+
if (tc.function?.name) toolCalls[tc.index].name = tc.function.name;
|
|
965
|
+
if (tc.function?.arguments) toolCalls[tc.index].args += tc.function.arguments;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (ev.choices?.[0]?.finish_reason) finishReason = ev.choices[0].finish_reason;
|
|
969
|
+
} catch {}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (currentText) assistantContent.push({ type: 'text', text: currentText });
|
|
974
|
+
for (const tc of Object.values(toolCalls)) {
|
|
975
|
+
let input = {};
|
|
976
|
+
try { input = JSON.parse(tc.args); } catch {}
|
|
977
|
+
assistantContent.push({ type: 'tool_use', id: tc.id, name: tc.name, input });
|
|
978
|
+
}
|
|
979
|
+
messages.push({ role: 'assistant', content: assistantContent });
|
|
980
|
+
|
|
981
|
+
if (finishReason !== 'tool_calls' && Object.keys(toolCalls).length === 0) break;
|
|
982
|
+
|
|
983
|
+
const toolUseBlocks = assistantContent.filter(b => b.type === 'tool_use');
|
|
984
|
+
if (toolUseBlocks.length === 0) break;
|
|
985
|
+
|
|
986
|
+
const toolResults = [];
|
|
987
|
+
for (const toolBlock of toolUseBlocks) {
|
|
988
|
+
if (isAborted()) break;
|
|
989
|
+
const result = await onToolCall(toolBlock.name, toolBlock.input);
|
|
990
|
+
toolResults.push({ type: 'tool_result', tool_use_id: toolBlock.id, content: typeof result === 'string' ? result : JSON.stringify(result) });
|
|
991
|
+
}
|
|
992
|
+
messages.push({ role: 'user', content: toolResults });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return { messages, stopReason: 'end' };
|
|
996
|
+
}
|
|
997
|
+
|
|
716
998
|
export function getApiKey(config, provider) {
|
|
717
999
|
// NHA Free (Liara) doesn't need an API key
|
|
718
1000
|
if (provider === 'nha') return 'nha-free-tier';
|