diff --git a/packages/tf-sync/src/tf_sync/__pycache__/__init__.cpython-313.pyc b/packages/tf-sync/src/tf_sync/__pycache__/__init__.cpython-313.pyc index 420116a7b..fbb1f7dbd 100644 Binary files a/packages/tf-sync/src/tf_sync/__pycache__/__init__.cpython-313.pyc and b/packages/tf-sync/src/tf_sync/__pycache__/__init__.cpython-313.pyc differ diff --git a/packages/tf-sync/src/tf_sync/__pycache__/agents.cpython-313.pyc b/packages/tf-sync/src/tf_sync/__pycache__/agents.cpython-313.pyc index 5e39ba86a..0821e555c 100644 Binary files a/packages/tf-sync/src/tf_sync/__pycache__/agents.cpython-313.pyc and b/packages/tf-sync/src/tf_sync/__pycache__/agents.cpython-313.pyc differ diff --git a/packages/tf-sync/src/tf_sync/__pycache__/config.cpython-313.pyc b/packages/tf-sync/src/tf_sync/__pycache__/config.cpython-313.pyc index 5b8500a98..a02a85aee 100644 Binary files a/packages/tf-sync/src/tf_sync/__pycache__/config.cpython-313.pyc and b/packages/tf-sync/src/tf_sync/__pycache__/config.cpython-313.pyc differ diff --git a/packages/tf-sync/src/tf_sync/__pycache__/mcp.cpython-313.pyc b/packages/tf-sync/src/tf_sync/__pycache__/mcp.cpython-313.pyc index 3f5410e27..8bb09ba8c 100644 Binary files a/packages/tf-sync/src/tf_sync/__pycache__/mcp.cpython-313.pyc and b/packages/tf-sync/src/tf_sync/__pycache__/mcp.cpython-313.pyc differ diff --git a/packages/tf-sync/src/tf_sync/__pycache__/tools.cpython-313.pyc b/packages/tf-sync/src/tf_sync/__pycache__/tools.cpython-313.pyc index 18f873215..3894eee90 100644 Binary files a/packages/tf-sync/src/tf_sync/__pycache__/tools.cpython-313.pyc and b/packages/tf-sync/src/tf_sync/__pycache__/tools.cpython-313.pyc differ diff --git a/packages/tf-sync/src/tf_sync/tools.py b/packages/tf-sync/src/tf_sync/tools.py index e2584ed45..dfbf9f7f2 100644 --- a/packages/tf-sync/src/tf_sync/tools.py +++ b/packages/tf-sync/src/tf_sync/tools.py @@ -35,6 +35,14 @@ class SyncedTool(BaseModel): authorisation_type: Optional[str] = None auth_via: str = "tf_proxy" + + # Coder agent specific fields for prompting/model configuration + interpolation_string: Optional[str] = None + goals: Optional[str] = None + temperature: Optional[float] = None + max_tokens: Optional[int] = None + llm_base_model: Optional[str] = None + llm_provider: Optional[str] = None class ToolSyncResult(BaseModel): @@ -121,7 +129,7 @@ def parse_agent(agent) -> SyncedTool: agent: Agent from TF SDK Returns: - SyncedTool instance + SyncedTool instance with full agent configuration """ return SyncedTool( id=agent.id, @@ -130,6 +138,14 @@ def parse_agent(agent) -> SyncedTool: tool_type=ToolType.CODER_AGENT, is_agent_skill=False, auth_via="tf_agent", + # Agent prompting configuration + interpolation_string=getattr(agent, 'interpolation_string', None), + goals=getattr(agent, 'goals', None), + # Agent model configuration + temperature=getattr(agent, 'temperature', None), + max_tokens=getattr(agent, 'max_tokens', None), + llm_base_model=getattr(agent, 'llm_base_model', None), + llm_provider=getattr(agent, 'llm_provider', None), ) diff --git a/packages/tf-sync/test_tools.py b/packages/tf-sync/test_tools.py new file mode 100644 index 000000000..3e7abd4c8 --- /dev/null +++ b/packages/tf-sync/test_tools.py @@ -0,0 +1,310 @@ +""" +End-to-end tests for ToothFairyAI agent sync and prompt injection. +Tests the entire flow from Python sync to TypeScript agent loading to prompt building. +""" + +import json +import os +import tempfile +from pathlib import Path + +# Test 1: Verify parse_agent extracts all required fields +def test_parse_agent_extracts_all_fields(): + print("\n" + "=" * 80) + print("TEST 1: Verify parse_agent extracts all required fields") + print("=" * 80) + + from tf_sync.tools import parse_agent, SyncedTool, ToolType + + # Create a mock agent with all the fields we need + class MockAgent: + id = "test-agent-123" + label = "Code Reviewer" + description = "Reviews code for quality and best practices" + interpolation_string = "You are a code reviewer. Always check for bugs and suggest improvements." + goals = "Review code thoroughly. Provide actionable feedback. Ensure code quality." + temperature = 0.3 + max_tokens = 4096 + llm_base_model = "claude-3-5-sonnet" + llm_provider = "toothfairyai" + mode = "coder" + + agent = MockAgent() + result = parse_agent(agent) + + print(f"\nParsed tool:") + print(f" id: {result.id}") + print(f" name: {result.name}") + print(f" description: {result.description}") + print(f" tool_type: {result.tool_type}") + print(f" interpolation_string: {result.interpolation_string}") + print(f" goals: {result.goals}") + print(f" temperature: {result.temperature}") + print(f" max_tokens: {result.max_tokens}") + print(f" llm_base_model: {result.llm_base_model}") + print(f" llm_provider: {result.llm_provider}") + + # Verify all fields are populated + assert result.id == "test-agent-123", f"Expected id='test-agent-123', got '{result.id}'" + assert result.name == "Code Reviewer", f"Expected name='Code Reviewer', got '{result.name}'" + assert result.tool_type == ToolType.CODER_AGENT, f"Expected CODER_AGENT, got {result.tool_type}" + assert result.interpolation_string == "You are a code reviewer. Always check for bugs and suggest improvements.", \ + f"interpolation_string not set correctly" + assert result.goals == "Review code thoroughly. Provide actionable feedback. Ensure code quality.", \ + f"goals not set correctly" + assert result.temperature == 0.3, f"Expected temperature=0.3, got {result.temperature}" + assert result.max_tokens == 4096, f"Expected max_tokens=4096, got {result.max_tokens}" + assert result.llm_base_model == "claude-3-5-sonnet", f"llm_base_model not set correctly" + assert result.llm_provider == "toothfairyai", f"llm_provider not set correctly" + + print("\n✅ TEST 1 PASSED: parse_agent extracts all fields correctly") + return True + + +# Test 2: Verify model mapping for different llm_provider values +def test_model_mapping_for_tf_providers(): + print("\n" + "=" * 80) + print("TEST 2: Verify model mapping for different llm_provider values") + print("=" * 80) + + from tf_sync.tools import parse_agent, ToolType + + # Test with None provider (should map to toothfairyai in TypeScript) + class MockAgentNone: + id = "test-agent-none" + label = "Agent None" + description = "Test" + interpolation_string = "Test prompt" + goals = "Test goals" + temperature = 0.7 + max_tokens = 2048 + llm_base_model = "gpt-4" + llm_provider = None + mode = "coder" + + # Test with "toothfairyai" provider + class MockAgentTF: + id = "test-agent-tf" + label = "Agent TF" + description = "Test" + interpolation_string = "Test prompt" + goals = "Test goals" + temperature = 0.7 + max_tokens = 2048 + llm_base_model = "gpt-4" + llm_provider = "toothfairyai" + mode = "coder" + + # Test with "tf" provider + class MockAgentTFShort: + id = "test-agent-tf-short" + label = "Agent TF Short" + description = "Test" + interpolation_string = "Test prompt" + goals = "Test goals" + temperature = 0.7 + max_tokens = 2048 + llm_base_model = "gpt-4" + llm_provider = "tf" + mode = "coder" + + # Test with external provider (should NOT map to toothfairyai) + class MockAgentExternal: + id = "test-agent-external" + label = "Agent External" + description = "Test" + interpolation_string = "Test prompt" + goals = "Test goals" + temperature = 0.7 + max_tokens = 2048 + llm_base_model = "claude-3-5-sonnet" + llm_provider = "anthropic" + mode = "coder" + + results = { + "None provider": parse_agent(MockAgentNone()), + "toothfairyai provider": parse_agent(MockAgentTF()), + "tf provider": parse_agent(MockAgentTFShort()), + "anthropic provider": parse_agent(MockAgentExternal()), + } + + for name, result in results.items(): + print(f"\n{name}:") + print(f" llm_provider: {result.llm_provider}") + print(f" llm_base_model: {result.llm_base_model}") + + # The TypeScript code will check if llm_provider is None, "toothfairyai", or "tf" + # and map to toothfairyai provider. Here we just verify the values are preserved. + assert results["None provider"].llm_provider is None + assert results["toothfairyai provider"].llm_provider == "toothfairyai" + assert results["tf provider"].llm_provider == "tf" + assert results["anthropic provider"].llm_provider == "anthropic" + + print("\n✅ TEST 2 PASSED: Provider values are preserved correctly for TypeScript mapping") + return True + + +# Test 3: Verify SyncedTool serializes correctly to JSON +def test_synced_tool_json_serialization(): + print("\n" + "=" * 80) + print("TEST 3: Verify SyncedTool serializes correctly to JSON") + print("=" * 80) + + from tf_sync.tools import parse_agent + + class MockAgent: + id = "test-agent-json" + label = "JSON Test Agent" + description = "Test JSON serialization" + interpolation_string = "You are a JSON test agent." + goals = "Test JSON output." + temperature = 0.5 + max_tokens = 8192 + llm_base_model = "gpt-4-turbo" + llm_provider = "toothfairyai" + mode = "coder" + + result = parse_agent(MockAgent()) + + # Simulate what tfcode.js does + tool_data = { + "id": result.id, + "name": result.name, + "description": result.description, + "tool_type": result.tool_type.value, + "request_type": result.request_type.value if result.request_type else None, + "url": result.url, + "auth_via": result.auth_via, + "interpolation_string": result.interpolation_string, + "goals": result.goals, + "temperature": result.temperature, + "max_tokens": result.max_tokens, + "llm_base_model": result.llm_base_model, + "llm_provider": result.llm_provider, + } + + print(f"\nSerialized JSON:") + print(json.dumps(tool_data, indent=2)) + + # Verify all fields are present in JSON + assert tool_data["id"] == "test-agent-json" + assert tool_data["name"] == "JSON Test Agent" + assert tool_data["tool_type"] == "coder_agent" + assert tool_data["interpolation_string"] == "You are a JSON test agent." + assert tool_data["goals"] == "Test JSON output." + assert tool_data["temperature"] == 0.5 + assert tool_data["max_tokens"] == 8192 + assert tool_data["llm_base_model"] == "gpt-4-turbo" + assert tool_data["llm_provider"] == "toothfairyai" + + print("\n✅ TEST 3 PASSED: SyncedTool serializes correctly to JSON") + return True + + +# Test 4: Create a mock tools.json and verify TypeScript can parse it +def test_tools_json_format(): + print("\n" + "=" * 80) + print("TEST 4: Verify tools.json format matches TypeScript expectations") + print("=" * 80) + + # Create a mock tools.json content + mock_tools = { + "success": True, + "tools": [ + { + "id": "coder-agent-1", + "name": "Code Reviewer", + "description": "Reviews code for quality and best practices", + "tool_type": "coder_agent", + "request_type": None, + "url": None, + "auth_via": "tf_agent", + "interpolation_string": "You are a code reviewer. Your job is to review code thoroughly and provide actionable feedback.", + "goals": "Review all code changes. Identify bugs. Suggest improvements. Ensure best practices.", + "temperature": 0.3, + "max_tokens": 4096, + "llm_base_model": "claude-3-5-sonnet", + "llm_provider": "toothfairyai", + }, + { + "id": "coder-agent-2", + "name": "Test Writer", + "description": "Writes comprehensive tests", + "tool_type": "coder_agent", + "request_type": None, + "url": None, + "auth_via": "tf_agent", + "interpolation_string": "You are a test writer. Write comprehensive tests for all code.", + "goals": "Write unit tests. Write integration tests. Ensure code coverage.", + "temperature": 0.5, + "max_tokens": 8192, + "llm_base_model": None, + "llm_provider": None, # Should map to toothfairyai in TypeScript + }, + ], + "by_type": { + "coder_agent": 2, + }, + } + + print(f"\nMock tools.json content:") + print(json.dumps(mock_tools, indent=2)) + + # Verify the structure matches what TypeScript expects + assert mock_tools["success"] == True + assert len(mock_tools["tools"]) == 2 + + for tool in mock_tools["tools"]: + assert "id" in tool + assert "name" in tool + assert "tool_type" in tool + assert "interpolation_string" in tool + assert "goals" in tool + assert "temperature" in tool + assert "max_tokens" in tool + assert "llm_base_model" in tool + assert "llm_provider" in tool + assert tool["tool_type"] == "coder_agent" + + print("\n✅ TEST 4 PASSED: tools.json format matches TypeScript expectations") + return True + + +def run_all_tests(): + """Run all tests in sequence.""" + print("\n" + "=" * 80) + print("RUNNING ALL PYTHON TESTS") + print("=" * 80) + + tests = [ + test_parse_agent_extracts_all_fields, + test_model_mapping_for_tf_providers, + test_synced_tool_json_serialization, + test_tools_json_format, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + if test(): + passed += 1 + else: + failed += 1 + except Exception as e: + print(f"\n❌ TEST FAILED: {test.__name__}") + print(f" Error: {e}") + failed += 1 + + print("\n" + "=" * 80) + print(f"PYTHON TEST RESULTS: {passed} passed, {failed} failed") + print("=" * 80) + + return failed == 0 + + +if __name__ == "__main__": + import sys + success = run_all_tests() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/packages/tfcode/bin/tfcode.js b/packages/tfcode/bin/tfcode.js index 086d9bad2..5ba9c022d 100755 --- a/packages/tfcode/bin/tfcode.js +++ b/packages/tfcode/bin/tfcode.js @@ -1,63 +1,73 @@ #!/usr/bin/env node -import { spawn } from 'child_process'; -import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { homedir } from 'os'; -import * as readline from 'readline'; -import { fileURLToPath } from 'url'; +import { spawn } from "child_process" +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" +import { join, dirname } from "path" +import { homedir } from "os" +import * as readline from "readline" +import { fileURLToPath } from "url" -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) -const TFCODE_DIR = join(homedir(), '.tfcode'); -const TOOLS_FILE = join(TFCODE_DIR, 'tools.json'); -const CREDENTIALS_FILE = join(TFCODE_DIR, 'credentials.json'); -const CONFIG_FILE = join(TFCODE_DIR, 'config.json'); +const TFCODE_DIR = join(homedir(), ".tfcode") +const TOOLS_FILE = join(TFCODE_DIR, "tools.json") +const CREDENTIALS_FILE = join(TFCODE_DIR, "credentials.json") +const CONFIG_FILE = join(TFCODE_DIR, "config.json") const COLORS = { - reset: '\x1b[0m', - bold: '\x1b[1m', - green: '\x1b[32m', - red: '\x1b[31m', - cyan: '\x1b[36m', - dim: '\x1b[90m', - yellow: '\x1b[33m', - magenta: '\x1b[35m' -}; + reset: "\x1b[0m", + bold: "\x1b[1m", + green: "\x1b[32m", + red: "\x1b[31m", + cyan: "\x1b[36m", + dim: "\x1b[90m", + yellow: "\x1b[33m", + magenta: "\x1b[35m", +} -function log(msg) { console.log(msg); } -function success(msg) { console.log(`${COLORS.green}✓${COLORS.reset} ${msg}`); } -function error(msg) { console.error(`${COLORS.red}✗${COLORS.reset} ${msg}`); } -function info(msg) { console.log(`${COLORS.cyan}ℹ${COLORS.reset} ${msg}`); } +function log(msg) { + console.log(msg) +} +function success(msg) { + console.log(`${COLORS.green}✓${COLORS.reset} ${msg}`) +} +function error(msg) { + console.error(`${COLORS.red}✗${COLORS.reset} ${msg}`) +} +function info(msg) { + console.log(`${COLORS.cyan}ℹ${COLORS.reset} ${msg}`) +} function ensureConfigDir() { - if (!existsSync(TFCODE_DIR)) mkdirSync(TFCODE_DIR, { recursive: true }); + if (!existsSync(TFCODE_DIR)) mkdirSync(TFCODE_DIR, { recursive: true }) } function loadConfig() { const envConfig = { workspace_id: process.env.TF_WORKSPACE_ID, api_key: process.env.TF_API_KEY, - region: process.env.TF_REGION - }; - if (envConfig.workspace_id && envConfig.api_key) return envConfig; - if (existsSync(CONFIG_FILE)) { - try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {} + region: process.env.TF_REGION, } - return null; + if (envConfig.workspace_id && envConfig.api_key) return envConfig + if (existsSync(CONFIG_FILE)) { + try { + return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) + } catch {} + } + return null } function saveConfig(config) { - ensureConfigDir(); - writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + ensureConfigDir() + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)) } function runPythonSync(method, config = null) { - const wsId = config?.workspace_id || process.env.TF_WORKSPACE_ID || ''; - const apiKey = config?.api_key || process.env.TF_API_KEY || ''; - const region = config?.region || process.env.TF_REGION || 'au'; - + const wsId = config?.workspace_id || process.env.TF_WORKSPACE_ID || "" + const apiKey = config?.api_key || process.env.TF_API_KEY || "" + const region = config?.region || process.env.TF_REGION || "au" + const pythonCode = ` import json, sys, os try: @@ -77,222 +87,430 @@ try: elif method == "sync": config = load_config() result = sync_tools(config) - tools_data = [{"id": t.id, "name": t.name, "description": t.description, "tool_type": t.tool_type.value, "request_type": t.request_type.value if t.request_type else None, "url": t.url, "auth_via": t.auth_via} for t in result.tools] + tools_data = [{"id": t.id, "name": t.name, "description": t.description, "tool_type": t.tool_type.value, "request_type": t.request_type.value if t.request_type else None, "url": t.url, "auth_via": t.auth_via, "interpolation_string": t.interpolation_string, "goals": t.goals, "temperature": t.temperature, "max_tokens": t.max_tokens, "llm_base_model": t.llm_base_model, "llm_provider": t.llm_provider} for t in result.tools] print(json.dumps({"success": result.success, "tools": tools_data, "by_type": result.by_type, "error": result.error})) except Exception as e: print(json.dumps({"success": False, "error": str(e)})) -`; +` return new Promise((resolve, reject) => { - const proc = spawn(process.env.TFCODE_PYTHON_PATH || 'python3', ['-c', pythonCode], { env: { ...process.env } }); - let stdout = '', stderr = ''; - proc.stdout.on('data', (d) => stdout += d); - proc.stderr.on('data', (d) => stderr += d); - proc.on('close', (code) => { - if (code !== 0 && !stdout) reject(new Error(`Python failed: ${stderr}`)); - else try { resolve(JSON.parse(stdout.trim())); } catch (e) { reject(new Error(`Parse error: ${stdout}`)); } - }); - proc.on('error', reject); - }); + const proc = spawn(process.env.TFCODE_PYTHON_PATH || "python3", ["-c", pythonCode], { env: { ...process.env } }) + let stdout = "", + stderr = "" + proc.stdout.on("data", (d) => (stdout += d)) + proc.stderr.on("data", (d) => (stderr += d)) + proc.on("close", (code) => { + if (code !== 0 && !stdout) reject(new Error(`Python failed: ${stderr}`)) + else + try { + resolve(JSON.parse(stdout.trim())) + } catch (e) { + reject(new Error(`Parse error: ${stdout}`)) + } + }) + proc.on("error", reject) + }) } function loadCachedTools() { - if (!existsSync(TOOLS_FILE)) return null; - try { return JSON.parse(readFileSync(TOOLS_FILE, 'utf-8')); } catch { return null; } + if (!existsSync(TOOLS_FILE)) return null + try { + return JSON.parse(readFileSync(TOOLS_FILE, "utf-8")) + } catch { + return null + } } function saveToolsCache(tools) { - ensureConfigDir(); - writeFileSync(TOOLS_FILE, JSON.stringify(tools, null, 2)); + ensureConfigDir() + writeFileSync(TOOLS_FILE, JSON.stringify(tools, null, 2)) } async function question(prompt) { return new Promise((resolve) => { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim()); }); - }); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + rl.question(prompt, (answer) => { + rl.close() + resolve(answer.trim()) + }) + }) } async function select(prompt, options) { - log(''); - log(prompt); - log(''); - options.forEach((opt, i) => log(` ${COLORS.cyan}${i + 1}.${COLORS.reset} ${opt}`)); - log(''); - const answer = await question('Select (1-' + options.length + '): '); - const idx = parseInt(answer) - 1; - return idx >= 0 && idx < options.length ? idx : 0; + log("") + log(prompt) + log("") + options.forEach((opt, i) => log(` ${COLORS.cyan}${i + 1}.${COLORS.reset} ${opt}`)) + log("") + const answer = await question("Select (1-" + options.length + "): ") + const idx = parseInt(answer) - 1 + return idx >= 0 && idx < options.length ? idx : 0 } async function interactiveSetup() { - log(''); - log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`); - log(`${COLORS.bold}${COLORS.magenta} tfcode Setup${COLORS.reset}`); - log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`); - log(''); - log('This will guide you through setting up your ToothFairyAI credentials.'); - log(''); - log(`${COLORS.dim}You can find your credentials at:${COLORS.reset}`); - log(`${COLORS.dim} https://app.toothfairyai.com → Settings → API Keys${COLORS.reset}`); - log(''); - - log(`${COLORS.bold}Step 1: Workspace ID${COLORS.reset}`); - log(`${COLORS.dim}This is your workspace UUID${COLORS.reset}`); - log(''); - const workspaceId = await question('Enter your Workspace ID: '); - if (!workspaceId) { error('Workspace ID is required'); process.exit(1); } - log(''); - - log(`${COLORS.bold}Step 2: API Key${COLORS.reset}`); - log(`${COLORS.dim}Paste or type your API key${COLORS.reset}`); - log(''); - const apiKey = await question('Enter your API Key: '); - if (!apiKey) { error('API Key is required'); process.exit(1); } - log(''); - - log(`${COLORS.bold}Step 3: Region${COLORS.reset}`); - const regions = ['dev (Development)', 'au (Australia)', 'eu (Europe)', 'us (United States)']; - const regionIdx = await select('Select your region:', regions); - const region = ['dev', 'au', 'eu', 'us'][regionIdx]; - - log(''); - log(`${COLORS.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`); - log(''); - log(`${COLORS.bold}Summary:${COLORS.reset}`); - log(` Workspace ID: ${workspaceId}`); - log(` API Key: ***${apiKey.slice(-4)}`); - log(` Region: ${region}`); - log(''); - - const confirm = await question('Save these credentials? (Y/n): '); - if (confirm.toLowerCase() === 'n' || confirm.toLowerCase() === 'no') { log('Setup cancelled.'); return; } - - const config = { workspace_id: workspaceId, api_key: apiKey, region }; - saveConfig(config); - success('Credentials saved to ~/.tfcode/config.json'); - log(''); - - const testNow = await question('Validate credentials now? (Y/n): '); - if (testNow.toLowerCase() === 'n' || testNow.toLowerCase() === 'no') return; - - log(''); - info('Validating credentials...'); - log(''); - + log("") + log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.magenta} tfcode Setup${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log("") + log("This will guide you through setting up your ToothFairyAI credentials.") + log("") + log(`${COLORS.dim}You can find your credentials at:${COLORS.reset}`) + log(`${COLORS.dim} https://app.toothfairyai.com → Settings → API Keys${COLORS.reset}`) + log("") + + log(`${COLORS.bold}Step 1: Workspace ID${COLORS.reset}`) + log(`${COLORS.dim}This is your workspace UUID${COLORS.reset}`) + log("") + const workspaceId = await question("Enter your Workspace ID: ") + if (!workspaceId) { + error("Workspace ID is required") + process.exit(1) + } + log("") + + log(`${COLORS.bold}Step 2: API Key${COLORS.reset}`) + log(`${COLORS.dim}Paste or type your API key${COLORS.reset}`) + log("") + const apiKey = await question("Enter your API Key: ") + if (!apiKey) { + error("API Key is required") + process.exit(1) + } + log("") + + log(`${COLORS.bold}Step 3: Region${COLORS.reset}`) + const regions = ["dev (Development)", "au (Australia)", "eu (Europe)", "us (United States)"] + const regionIdx = await select("Select your region:", regions) + const region = ["dev", "au", "eu", "us"][regionIdx] + + log("") + log(`${COLORS.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log("") + log(`${COLORS.bold}Summary:${COLORS.reset}`) + log(` Workspace ID: ${workspaceId}`) + log(` API Key: ***${apiKey.slice(-4)}`) + log(` Region: ${region}`) + log("") + + const confirm = await question("Save these credentials? (Y/n): ") + if (confirm.toLowerCase() === "n" || confirm.toLowerCase() === "no") { + log("Setup cancelled.") + return + } + + const config = { workspace_id: workspaceId, api_key: apiKey, region } + saveConfig(config) + success("Credentials saved to ~/.tfcode/config.json") + log("") + + const testNow = await question("Validate credentials now? (Y/n): ") + if (testNow.toLowerCase() === "n" || testNow.toLowerCase() === "no") return + + log("") + info("Validating credentials...") + log("") + try { - const result = await runPythonSync('validate', config); + const result = await runPythonSync("validate", config) if (result.success) { - success('Credentials valid!'); - log(` API URL: ${result.base_url}`); - log(` Workspace ID: ${result.workspace_id}`); - log(''); - - const syncNow = await question('Sync tools now? (Y/n): '); - if (syncNow.toLowerCase() === 'n' || syncNow.toLowerCase() === 'no') return; - - log(''); - info('Syncing tools...'); - log(''); - - const syncResult = await runPythonSync('sync', config); + success("Credentials valid!") + log(` API URL: ${result.base_url}`) + log(` Workspace ID: ${result.workspace_id}`) + log("") + + const syncNow = await question("Sync tools now? (Y/n): ") + if (syncNow.toLowerCase() === "n" || syncNow.toLowerCase() === "no") return + + log("") + info("Syncing tools...") + log("") + + const syncResult = await runPythonSync("sync", config) if (syncResult.success) { - saveToolsCache(syncResult); - success(`Synced ${syncResult.tools.length} tools`); + saveToolsCache(syncResult) + success(`Synced ${syncResult.tools.length} tools`) if (syncResult.by_type && Object.keys(syncResult.by_type).length > 0) { - log(''); - log('By type:'); - for (const [type, count] of Object.entries(syncResult.by_type)) log(` ${type}: ${count}`); + log("") + log("By type:") + for (const [type, count] of Object.entries(syncResult.by_type)) log(` ${type}: ${count}`) } } else { - error(`Sync failed: ${syncResult.error}`); + error(`Sync failed: ${syncResult.error}`) } } else { - error(`Validation failed: ${result.error}`); + error(`Validation failed: ${result.error}`) } } catch (e) { - error(`Failed: ${e.message}`); + error(`Failed: ${e.message}`) } - - log(''); - log(`${COLORS.bold}${COLORS.green}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`); - log(`${COLORS.bold}${COLORS.green} Setup Complete!${COLORS.reset}`); - log(`${COLORS.bold}${COLORS.green}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`); - log(''); - log('Commands:'); - log(` ${COLORS.cyan}tfcode validate${COLORS.reset} Check credentials`); - log(` ${COLORS.cyan}tfcode sync${COLORS.reset} Sync tools`); - log(` ${COLORS.cyan}tfcode tools list${COLORS.reset} List tools`); - log(''); + + log("") + log(`${COLORS.bold}${COLORS.green}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.green} Setup Complete!${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.green}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log("") + log("Commands:") + log(` ${COLORS.cyan}tfcode validate${COLORS.reset} Check credentials`) + log(` ${COLORS.cyan}tfcode sync${COLORS.reset} Sync tools`) + log(` ${COLORS.cyan}tfcode tools list${COLORS.reset} List tools`) + log("") } function showHelp() { - log(''); - log(`${COLORS.bold}tfcode${COLORS.reset} - ToothFairyAI's AI coding agent`); - log(''); - log('Commands:'); - log(` ${COLORS.cyan}tfcode setup${COLORS.reset} Interactive credential setup`); - log(` ${COLORS.cyan}tfcode validate${COLORS.reset} Test credentials`); - log(` ${COLORS.cyan}tfcode sync${COLORS.reset} Sync tools from workspace`); - log(` ${COLORS.cyan}tfcode tools list${COLORS.reset} List synced tools`); - log(` ${COLORS.cyan}tfcode --help${COLORS.reset} Show this help`); - log(` ${COLORS.cyan}tfcode --version${COLORS.reset} Show version`); - log(''); - log(`${COLORS.dim}For full TUI, run from source:${COLORS.reset}`); - log(`${COLORS.dim} bun run packages/tfcode/src/index.ts${COLORS.reset}`); - log(''); + log("") + log(`${COLORS.bold}tfcode${COLORS.reset} - ToothFairyAI's AI coding agent`) + log("") + log("Commands:") + log(` ${COLORS.cyan}tfcode setup${COLORS.reset} Interactive credential setup`) + log(` ${COLORS.cyan}tfcode validate${COLORS.reset} Test credentials`) + log(` ${COLORS.cyan}tfcode sync${COLORS.reset} Sync tools from workspace`) + log(` ${COLORS.cyan}tfcode tools list${COLORS.reset} List synced tools`) + log(` ${COLORS.cyan}tfcode test-agent ${COLORS.reset} Test agent prompt injection`) + log(` ${COLORS.cyan}tfcode debug${COLORS.reset} Show debug info`) + log(` ${COLORS.cyan}tfcode --help${COLORS.reset} Show this help`) + log(` ${COLORS.cyan}tfcode --version${COLORS.reset} Show version`) + log("") + log(`${COLORS.dim}For full TUI, run from source:${COLORS.reset}`) + log(`${COLORS.dim} bun run packages/tfcode/src/index.ts${COLORS.reset}`) + log("") } -const args = process.argv.slice(2); -const command = args[0]; - -if (args.includes('--help') || args.includes('-h')) { - showHelp(); -} else if (args.includes('--version') || args.includes('-v')) { - log('tfcode v1.0.0-beta.9'); -} else if (command === 'setup') { - interactiveSetup(); -} else if (command === 'validate') { - (async () => { - const config = loadConfig(); - if (!config) { error('No credentials. Run: tfcode setup'); process.exit(1); } - info('Validating...'); - try { - const result = await runPythonSync('validate', config); - if (result.success) { success('Credentials valid'); log(` API URL: ${result.base_url}`); } - else { error(`Failed: ${result.error}`); process.exit(1); } - } catch (e) { error(`Failed: ${e.message}`); process.exit(1); } - })(); -} else if (command === 'sync') { - (async () => { - const config = loadConfig(); - if (!config) { error('No credentials. Run: tfcode setup'); process.exit(1); } - info('Syncing tools...'); - try { - const result = await runPythonSync('sync', config); - if (result.success) { - saveToolsCache(result); - success(`Synced ${result.tools.length} tools`); - if (result.by_type) { log(''); log('By type:'); for (const [t, c] of Object.entries(result.by_type)) log(` ${t}: ${c}`); } - } else { error(`Failed: ${result.error}`); process.exit(1); } - } catch (e) { error(`Failed: ${e.message}`); process.exit(1); } - })(); -} else if (command === 'tools' && args[1] === 'list') { - const cached = loadCachedTools(); - if (!cached?.success) { error('No tools. Run: tfcode sync'); process.exit(1); } - let tools = cached.tools; - if (args[3] === '--type' && args[4]) tools = tools.filter(t => t.tool_type === args[4]); - log(`\n${tools.length} tool(s):\n`); - for (const t of tools) { - log(` ${COLORS.cyan}${t.name}${COLORS.reset}`); - log(` Type: ${t.tool_type}`); - if (t.description) log(` ${COLORS.dim}${t.description.slice(0, 60)}${COLORS.reset}`); - log(` Auth: ${t.auth_via}\n`); +function showDebugInfo() { + log("") + log(`${COLORS.bold}Debug Information${COLORS.reset}`) + log("") + log(` ${COLORS.bold}TFCODE_DIR:${COLORS.reset} ${TFCODE_DIR}`) + log(` ${COLORS.bold}TOOLS_FILE:${COLORS.reset} ${TOOLS_FILE}`) + log(` ${COLORS.bold}CONFIG_FILE:${COLORS.reset} ${CONFIG_FILE}`) + log(` ${COLORS.bold}CREDENTIALS_FILE:${COLORS.reset} ${CREDENTIALS_FILE}`) + log("") + log(` ${COLORS.bold}tools.json exists:${COLORS.reset} ${existsSync(TOOLS_FILE)}`) + if (existsSync(TOOLS_FILE)) { + const tools = loadCachedTools() + log(` ${COLORS.bold}tools.json valid:${COLORS.reset} ${tools?.success ?? false}`) + log(` ${COLORS.bold}tools count:${COLORS.reset} ${tools?.tools?.length ?? 0}`) + const coderAgents = tools?.tools?.filter((t) => t.tool_type === "coder_agent") ?? [] + log(` ${COLORS.bold}coder_agent count:${COLORS.reset} ${coderAgents.length}`) + if (coderAgents.length > 0) { + log("") + log(` ${COLORS.bold}Coder Agents:${COLORS.reset}`) + coderAgents.forEach((a) => { + log(` - ${a.name} (id: ${a.id})`) + log(` interpolation_string: ${a.interpolation_string ? "YES" : "NO"}`) + log(` goals: ${a.goals ? "YES" : "NO"}`) + log(` llm_provider: ${a.llm_provider ?? "(null)"}`) + log(` llm_base_model: ${a.llm_base_model ?? "(null)"}`) + }) + } } + log("") +} + +function testAgentPrompt(agentId) { + const tools = loadCachedTools() + if (!tools?.success) { + error("No tools. Run: tfcode sync") + process.exit(1) + } + + const agent = tools.tools.find((t) => t.id === agentId || t.name === agentId) + if (!agent) { + error(`Agent not found: ${agentId}`) + log("") + log("Available coder agents:") + tools.tools + .filter((t) => t.tool_type === "coder_agent") + .forEach((t) => { + log(` ${COLORS.cyan}${t.id}${COLORS.reset} - ${t.name}`) + }) + process.exit(1) + } + + log("") + log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.magenta} Agent Data from tools.json${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log("") + log(` ${COLORS.bold}id:${COLORS.reset} ${agent.id}`) + log(` ${COLORS.bold}name:${COLORS.reset} ${agent.name}`) + log(` ${COLORS.bold}description:${COLORS.reset} ${agent.description || "(none)"}`) + log(` ${COLORS.bold}tool_type:${COLORS.reset} ${agent.tool_type}`) + log(` ${COLORS.bold}auth_via:${COLORS.reset} ${agent.auth_via}`) + log("") + log(` ${COLORS.bold}interpolation_string:${COLORS.reset}`) + if (agent.interpolation_string) { + log(` ${agent.interpolation_string.substring(0, 200)}${agent.interpolation_string.length > 200 ? "..." : ""}`) + } else { + log(` ${COLORS.dim}(none)${COLORS.reset}`) + } + log("") + log(` ${COLORS.bold}goals:${COLORS.reset}`) + if (agent.goals) { + log(` ${agent.goals.substring(0, 200)}${agent.goals.length > 200 ? "..." : ""}`) + } else { + log(` ${COLORS.dim}(none)${COLORS.reset}`) + } + log("") + log(` ${COLORS.bold}temperature:${COLORS.reset} ${agent.temperature ?? "(none)"}`) + log(` ${COLORS.bold}max_tokens:${COLORS.reset} ${agent.max_tokens ?? "(none)"}`) + log(` ${COLORS.bold}llm_base_model:${COLORS.reset} ${agent.llm_base_model ?? "(none)"}`) + log(` ${COLORS.bold}llm_provider:${COLORS.reset} ${agent.llm_provider ?? "(none)"}`) + log("") + + // Build highlighted instructions + const isTFProvider = !agent.llm_provider || agent.llm_provider === "toothfairyai" || agent.llm_provider === "tf" + + const hasPrompt = agent.interpolation_string && agent.interpolation_string.trim().length > 0 + const hasGoals = agent.goals && agent.goals.trim().length > 0 + + log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.magenta} Model Mapping${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log("") + log(` ${COLORS.bold}isTFProvider:${COLORS.reset} ${isTFProvider}`) + log( + ` ${COLORS.bold}mapped model:${COLORS.reset} ${isTFProvider && agent.llm_base_model ? `toothfairyai/${agent.llm_base_model}` : "(no mapping)"}`, + ) + log("") + + log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.magenta} Highlighted Instructions Preview${COLORS.reset}`) + log(`${COLORS.bold}${COLORS.magenta}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${COLORS.reset}`) + log("") + + if (!hasPrompt && !hasGoals) { + log( + ` ${COLORS.dim}(No interpolation_string or goals - no highlighted instructions will be generated)${COLORS.reset}`, + ) + } else { + log("") + log("═══════════════════════════════════════════════════════════════════════════════") + log("⚠️ ULTRA IMPORTANT - AGENT CONFIGURATION ⚠️") + log("═══════════════════════════════════════════════════════════════════════════════") + log("") + log(`You are acting as the agent: "${agent.name}"`) + if (agent.description) { + log(`Description: ${agent.description}`) + } + log("") + log("The following instructions and goals are MANDATORY and MUST be followed") + log("with the HIGHEST PRIORITY. These override any conflicting default behaviors.") + log("═══════════════════════════════════════════════════════════════════════════════") + + if (hasPrompt) { + log("") + log("┌─────────────────────────────────────────────────────────────────────────────┐") + log(`│ 🎯 AGENT "${agent.name}" INSTRUCTIONS (CRITICAL - MUST FOLLOW) │`) + log("└─────────────────────────────────────────────────────────────────────────────┘") + log("") + log(agent.interpolation_string) + } + + if (hasGoals) { + log("") + log("┌─────────────────────────────────────────────────────────────────────────────┐") + log(`│ 🎯 AGENT "${agent.name}" GOALS (CRITICAL - MUST ACHIEVE) │`) + log("└─────────────────────────────────────────────────────────────────────────────┘") + log("") + log(agent.goals) + } + + log("") + log("═══════════════════════════════════════════════════════════════════════════════") + log(`⚠️ END OF ULTRA IMPORTANT AGENT "${agent.name}" CONFIGURATION ⚠️`) + log("═══════════════════════════════════════════════════════════════════════════════") + } + log("") +} + +const args = process.argv.slice(2) +const command = args[0] + +if (args.includes("--help") || args.includes("-h")) { + showHelp() +} else if (args.includes("--version") || args.includes("-v")) { + log("tfcode v1.0.0-beta.9") +} else if (command === "setup") { + interactiveSetup() +} else if (command === "validate") { + ;(async () => { + const config = loadConfig() + if (!config) { + error("No credentials. Run: tfcode setup") + process.exit(1) + } + info("Validating...") + try { + const result = await runPythonSync("validate", config) + if (result.success) { + success("Credentials valid") + log(` API URL: ${result.base_url}`) + } else { + error(`Failed: ${result.error}`) + process.exit(1) + } + } catch (e) { + error(`Failed: ${e.message}`) + process.exit(1) + } + })() +} else if (command === "sync") { + ;(async () => { + const config = loadConfig() + if (!config) { + error("No credentials. Run: tfcode setup") + process.exit(1) + } + info("Syncing tools...") + try { + const result = await runPythonSync("sync", config) + if (result.success) { + saveToolsCache(result) + success(`Synced ${result.tools.length} tools`) + if (result.by_type) { + log("") + log("By type:") + for (const [t, c] of Object.entries(result.by_type)) log(` ${t}: ${c}`) + } + } else { + error(`Failed: ${result.error}`) + process.exit(1) + } + } catch (e) { + error(`Failed: ${e.message}`) + process.exit(1) + } + })() +} else if (command === "tools" && args[1] === "list") { + const cached = loadCachedTools() + if (!cached?.success) { + error("No tools. Run: tfcode sync") + process.exit(1) + } + let tools = cached.tools + if (args[2] === "--type" && args[3]) tools = tools.filter((t) => t.tool_type === args[3]) + log(`\n${tools.length} tool(s):\n`) + for (const t of tools) { + log(` ${COLORS.cyan}${t.name}${COLORS.reset}`) + log(` Type: ${t.tool_type}`) + if (t.description) log(` ${COLORS.dim}${t.description.slice(0, 60)}${COLORS.reset}`) + log(` Auth: ${t.auth_via}\n`) + } +} else if (command === "test-agent") { + const agentId = args[1] + if (!agentId) { + error("Usage: tfcode test-agent ") + process.exit(1) + } + testAgentPrompt(agentId) +} else if (command === "debug") { + showDebugInfo() } else if (!command) { // Show help instead of trying TUI (TUI requires full build) - showHelp(); + showHelp() } else { - error(`Unknown command: ${command}`); - showHelp(); - process.exit(1); -} \ No newline at end of file + error(`Unknown command: ${command}`) + showHelp() + process.exit(1) +} diff --git a/packages/tfcode/src/agent/agent.ts b/packages/tfcode/src/agent/agent.ts index 3f846776d..9919ceece 100644 --- a/packages/tfcode/src/agent/agent.ts +++ b/packages/tfcode/src/agent/agent.ts @@ -8,6 +8,7 @@ import { Instance } from "../project/instance" import { Truncate } from "../tool/truncate" import { Auth } from "../auth" import { ProviderTransform } from "../provider/transform" +import os from "os" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -41,6 +42,7 @@ export namespace Agent { .optional(), variant: z.string().optional(), prompt: z.string().optional(), + goals: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), }) @@ -263,7 +265,8 @@ export namespace Agent { } async function loadTFCoderAgents(): Promise { - const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json") + // tools.json is synced to ~/.tfcode/tools.json by the CLI + const toolsPath = path.join(os.homedir(), ".tfcode", "tools.json") try { const content = await Bun.file(toolsPath).text() const data = JSON.parse(content) @@ -271,19 +274,33 @@ export namespace Agent { return data.tools .filter((t: any) => t.tool_type === "coder_agent") - .map( - (t: any): Info => ({ + .map((t: any): Info => { + // Map model for ToothFairyAI providers only + // Only map when llm_provider is None, "toothfairyai", or "tf" + const isTFProvider = !t.llm_provider || t.llm_provider === "toothfairyai" || t.llm_provider === "tf" + + const model = + isTFProvider && t.llm_base_model + ? { modelID: t.llm_base_model as ModelID, providerID: "toothfairyai" as ProviderID } + : undefined + + return { name: t.name, description: t.description, mode: "primary" as const, permission: Permission.fromConfig({ "*": "allow" }), native: false, + prompt: t.interpolation_string, + goals: t.goals, + temperature: t.temperature, + model, options: { tf_agent_id: t.id, tf_auth_via: t.auth_via, + tf_max_tokens: t.max_tokens, }, - }), - ) + } + }) } catch { return [] } diff --git a/packages/tfcode/src/session/llm.ts b/packages/tfcode/src/session/llm.ts index dad588be4..a335c22a7 100644 --- a/packages/tfcode/src/session/llm.ts +++ b/packages/tfcode/src/session/llm.ts @@ -68,10 +68,16 @@ export namespace LLM { const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth" const system: string[] = [] + + // Build highlighted agent instructions for ToothFairyAI agents + const tfHighlightedInstructions = buildTFAgentInstructions(input.agent) + system.push( [ // use agent prompt otherwise provider prompt ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + // highlighted TF agent instructions (if any) + ...(tfHighlightedInstructions ? [tfHighlightedInstructions] : []), // any custom prompt passed into this call ...input.system, // any custom prompt from last user message @@ -168,7 +174,7 @@ export namespace LLM { const maxOutputTokens = isOpenaiOauth || provider.id.includes("github-copilot") ? undefined - : ProviderTransform.maxOutputTokens(input.model) + : (input.agent.options?.tf_max_tokens ?? ProviderTransform.maxOutputTokens(input.model)) const tools = await resolveTools(input) @@ -320,4 +326,63 @@ export namespace LLM { } return false } + + /** + * Build highlighted instructions for ToothFairyAI agents. + * When a TF agent is selected, its instructions and goals are highlighted + * as ULTRA IMPORTANT to ensure they are followed with highest priority. + */ + function buildTFAgentInstructions(agent: Agent.Info): string | null { + // Only highlight for ToothFairyAI agents (non-native with tf_agent_id) + const isTFAgent = !agent.native && agent.options?.tf_agent_id + if (!isTFAgent) return null + + const parts: string[] = [] + + // Check if agent has custom prompt (interpolation_string) or goals + const hasPrompt = agent.prompt && agent.prompt.trim().length > 0 + const hasGoals = agent.goals && agent.goals.trim().length > 0 + + if (!hasPrompt && !hasGoals) return null + + parts.push("") + parts.push("═══════════════════════════════════════════════════════════════════════════════") + parts.push("⚠️ ULTRA IMPORTANT - AGENT CONFIGURATION ⚠️") + parts.push("═══════════════════════════════════════════════════════════════════════════════") + parts.push("") + parts.push(`You are acting as the agent: "${agent.name}"`) + if (agent.description) { + parts.push(`Description: ${agent.description}`) + } + parts.push("") + parts.push("The following instructions and goals are MANDATORY and MUST be followed") + parts.push("with the HIGHEST PRIORITY. These override any conflicting default behaviors.") + parts.push("═══════════════════════════════════════════════════════════════════════════════") + + if (hasPrompt) { + parts.push("") + parts.push("┌─────────────────────────────────────────────────────────────────────────────┐") + parts.push(`│ 🎯 AGENT "${agent.name}" INSTRUCTIONS (CRITICAL - MUST FOLLOW) │`) + parts.push("└─────────────────────────────────────────────────────────────────────────────┘") + parts.push("") + parts.push(agent.prompt!) + } + + if (hasGoals) { + parts.push("") + parts.push("┌─────────────────────────────────────────────────────────────────────────────┐") + parts.push(`│ 🎯 AGENT "${agent.name}" GOALS (CRITICAL - MUST ACHIEVE) │`) + parts.push("└─────────────────────────────────────────────────────────────────────────────┘") + parts.push("") + parts.push(agent.goals!) + } + + parts.push("") + parts.push("═══════════════════════════════════════════════════════════════════════════════") + parts.push(`⚠️ END OF ULTRA IMPORTANT AGENT "${agent.name}" CONFIGURATION ⚠️`) + parts.push("═══════════════════════════════════════════════════════════════════════════════") + parts.push("") + + return parts.join("\n") + } } diff --git a/packages/tfcode/test/agent/tf-agent.test.ts b/packages/tfcode/test/agent/tf-agent.test.ts new file mode 100644 index 000000000..4320dea5b --- /dev/null +++ b/packages/tfcode/test/agent/tf-agent.test.ts @@ -0,0 +1,492 @@ +import { afterAll, beforeAll, beforeEach, afterEach, test, expect, describe } from "bun:test" +import path from "path" +import fs from "fs" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Agent } from "../../src/agent/agent" +import { LLM } from "../../src/session/llm" +import { Provider } from "../../src/provider/provider" +import { Global } from "../../src/global" +import { Filesystem } from "../../src/util/filesystem" +import { ModelsDev } from "../../src/provider/models" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { SessionID, MessageID } from "../../src/session/schema" +import type { MessageV2 } from "../../src/session/message-v2" + +// Server for capturing LLM requests +const state = { + server: null as ReturnType | null, + queue: [] as Array<{ path: string; response: Response; resolve: (value: any) => void }>, +} + +function deferred() { + const result = {} as { promise: Promise; resolve: (value: T) => void } + result.promise = new Promise((resolve) => { + result.resolve = resolve + }) + return result +} + +function waitRequest(pathname: string, response: Response) { + const pending = deferred<{ url: URL; headers: Headers; body: Record }>() + state.queue.push({ path: pathname, response, resolve: pending.resolve }) + return pending.promise +} + +function createChatStream(text: string) { + const payload = + [ + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { content: text } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "stop" }], + })}`, + "data: [DONE]", + ].join("\n\n") + "\n\n" + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)) + controller.close() + }, + }) +} + +async function loadFixture(providerID: string, modelID: string) { + const fixturePath = path.join(import.meta.dir, "../tool/fixtures/models-api.json") + const data = await Filesystem.readJson>(fixturePath) + const provider = data[providerID] + if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`) + const model = provider.models[modelID] + if (!model) throw new Error(`Missing model in fixture: ${modelID}`) + return { provider, model } +} + +// Test the flow from tools.json -> Agent.Info -> highlighted instructions + +describe("ToothFairyAI Agent Loading", () => { + let originalDataPath: string + + beforeEach(async () => { + originalDataPath = Global.Path.data + const testDataDir = path.join(path.dirname(originalDataPath), "tf-agent-test-data") + ;(Global.Path as { data: string }).data = testDataDir + await fs.promises.mkdir(path.join(testDataDir, ".tfcode"), { recursive: true }) + }) + + afterEach(async () => { + await Instance.disposeAll() + ;(Global.Path as { data: string }).data = originalDataPath + }) + + describe("loadTFCoderAgents", () => { + test("parses tools.json with full agent data", async () => { + const toolsData = { + success: true, + tools: [ + { + id: "coder-agent-1", + name: "Code Reviewer", + description: "Reviews code for quality", + tool_type: "coder_agent", + request_type: null, + url: null, + auth_via: "tf_agent", + interpolation_string: "You are a code reviewer. Review code thoroughly.", + goals: "Identify bugs. Suggest improvements.", + temperature: 0.3, + max_tokens: 4096, + llm_base_model: "claude-3-5-sonnet", + llm_provider: "toothfairyai", + }, + { + id: "coder-agent-2", + name: "Test Writer", + description: "Writes tests", + tool_type: "coder_agent", + request_type: null, + url: null, + auth_via: "tf_agent", + interpolation_string: "You are a test writer.", + goals: "Write comprehensive tests.", + temperature: 0.5, + max_tokens: null, + llm_base_model: "gpt-4", + llm_provider: null, + }, + ], + by_type: { coder_agent: 2 }, + } + + const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json") + await fs.promises.writeFile(toolsPath, JSON.stringify(toolsData, null, 2)) + + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await Agent.list() + const codeReviewer = agents.find((a) => a.name === "Code Reviewer") + const testWriter = agents.find((a) => a.name === "Test Writer") + + expect(codeReviewer).toBeDefined() + expect(codeReviewer?.description).toBe("Reviews code for quality") + expect(codeReviewer?.prompt).toBe("You are a code reviewer. Review code thoroughly.") + expect(codeReviewer?.goals).toBe("Identify bugs. Suggest improvements.") + expect(codeReviewer?.temperature).toBe(0.3) + expect(codeReviewer?.native).toBe(false) + expect(codeReviewer?.options?.tf_agent_id).toBe("coder-agent-1") + expect(codeReviewer?.options?.tf_auth_via).toBe("tf_agent") + expect(codeReviewer?.options?.tf_max_tokens).toBe(4096) + expect(String(codeReviewer?.model?.providerID)).toBe("toothfairyai") + expect(String(codeReviewer?.model?.modelID)).toBe("claude-3-5-sonnet") + + expect(testWriter).toBeDefined() + expect(String(testWriter?.model?.providerID)).toBe("toothfairyai") + expect(String(testWriter?.model?.modelID)).toBe("gpt-4") + }, + }) + }) + + test("maps tf provider to toothfairyai", async () => { + const toolsData = { + success: true, + tools: [ + { + id: "tf-provider-agent", + name: "TF Provider Agent", + description: "Test", + tool_type: "coder_agent", + interpolation_string: "Test", + goals: "Test", + temperature: 0.7, + max_tokens: 2048, + llm_base_model: "test-model", + llm_provider: "tf", + }, + ], + } + + const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json") + await fs.promises.writeFile(toolsPath, JSON.stringify(toolsData)) + + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("TF Provider Agent") + expect(String(agent?.model?.providerID)).toBe("toothfairyai") + expect(String(agent?.model?.modelID)).toBe("test-model") + }, + }) + }) + + test("does not map external providers", async () => { + const toolsData = { + success: true, + tools: [ + { + id: "external-agent", + name: "External Agent", + description: "Test", + tool_type: "coder_agent", + interpolation_string: "Test", + goals: "Test", + temperature: 0.7, + max_tokens: 2048, + llm_base_model: "claude-3-5-sonnet", + llm_provider: "anthropic", + }, + ], + } + + const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json") + await fs.promises.writeFile(toolsPath, JSON.stringify(toolsData)) + + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("External Agent") + expect(agent?.model).toBeUndefined() + }, + }) + }) + + test("handles agent without interpolation_string or goals", async () => { + const toolsData = { + success: true, + tools: [ + { + id: "minimal-agent", + name: "Minimal Agent", + description: "Test", + tool_type: "coder_agent", + interpolation_string: null, + goals: null, + temperature: null, + max_tokens: null, + llm_base_model: null, + llm_provider: null, + }, + ], + } + + const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json") + await fs.promises.writeFile(toolsPath, JSON.stringify(toolsData)) + + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("Minimal Agent") + expect(agent).toBeDefined() + expect(agent?.prompt).toBeNull() + expect(agent?.goals).toBeNull() + expect(agent?.model).toBeUndefined() + }, + }) + }) + }) +}) + +// Separate describe block for LLM stream tests +describe("ToothFairyAI Agent Instructions in LLM Stream", () => { + let originalDataPath: string + + beforeAll(() => { + state.server = Bun.serve({ + port: 0, + async fetch(req) { + const next = state.queue.shift() + if (!next) return new Response("unexpected request", { status: 500 }) + const url = new URL(req.url) + const body = (await req.json()) as Record + next.resolve({ url, headers: req.headers, body }) + if (!url.pathname.endsWith(next.path)) return new Response("not found", { status: 404 }) + return next.response + }, + }) + }) + + afterAll(() => { + state.server?.stop() + }) + + beforeEach(async () => { + state.queue.length = 0 + originalDataPath = Global.Path.data + const testDataDir = path.join(path.dirname(originalDataPath), "tf-agent-test-data") + ;(Global.Path as { data: string }).data = testDataDir + await fs.promises.mkdir(path.join(testDataDir, ".tfcode"), { recursive: true }) + }) + + afterEach(async () => { + await Instance.disposeAll() + ;(Global.Path as { data: string }).data = originalDataPath + }) + + test("includes highlighted TF agent instructions in system prompt", async () => { + const server = state.server + if (!server) throw new Error("Server not initialized") + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + + // Setup TF agent with this model + const toolsData = { + success: true, + tools: [ + { + id: "code-reviewer-123", + name: "Code Reviewer", + description: "Reviews code for quality and best practices", + tool_type: "coder_agent", + auth_via: "tf_agent", + interpolation_string: "You are a code reviewer. Always check for bugs, security issues, and suggest improvements.", + goals: "Review all code thoroughly. Provide actionable feedback. Ensure code quality standards.", + temperature: 0.3, + max_tokens: 4096, + llm_base_model: modelID, + llm_provider: providerID, + }, + ], + } + + const toolsPath = path.join(Global.Path.data, ".tfcode", "tools.json") + await fs.promises.writeFile(toolsPath, JSON.stringify(toolsData, null, 2)) + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("I'll review your code."), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("Code Reviewer") + expect(agent).toBeDefined() + + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(modelID)) + const sessionID = SessionID.make("test-session") + const user: MessageV2.User = { + id: MessageID.make("user-1"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "Code Reviewer", + model: { providerID: ProviderID.make(providerID), modelID: ModelID.make(modelID) }, + } + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent: agent!, + system: [], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + for await (const _ of stream.fullStream) {} + + const capture = await request + const body = capture.body + const messages = body.messages as Array<{ role: string; content: string }> + + const systemMessage = messages.find((m) => m.role === "system") + expect(systemMessage).toBeDefined() + + const systemContent = systemMessage!.content + + expect(systemContent).toContain("ULTRA IMPORTANT - AGENT CONFIGURATION") + expect(systemContent).toContain('You are acting as the agent: "Code Reviewer"') + expect(systemContent).toContain("Reviews code for quality and best practices") + expect(systemContent).toContain("AGENT \"Code Reviewer\" INSTRUCTIONS") + expect(systemContent).toContain("You are a code reviewer. Always check for bugs, security issues, and suggest improvements.") + expect(systemContent).toContain("AGENT \"Code Reviewer\" GOALS") + expect(systemContent).toContain("Review all code thoroughly. Provide actionable feedback. Ensure code quality standards.") + }, + }) + }) + + test("does NOT include highlighted instructions for native agents", async () => { + const server = state.server + if (!server) throw new Error("Server not initialized") + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("build") + expect(agent).toBeDefined() + expect(agent?.native).toBe(true) + + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(modelID)) + const sessionID = SessionID.make("test-session") + const user: MessageV2.User = { + id: MessageID.make("user-1"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderID.make(providerID), modelID: ModelID.make(modelID) }, + } + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent: agent!, + system: [], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + for await (const _ of stream.fullStream) {} + + const capture = await request + const body = capture.body + const messages = body.messages as Array<{ role: string; content: string }> + + const systemMessage = messages.find((m) => m.role === "system") + expect(systemMessage).toBeDefined() + + const systemContent = systemMessage!.content + + expect(systemContent).not.toContain("ULTRA IMPORTANT - AGENT CONFIGURATION") + expect(systemContent).not.toContain("You are acting as the agent:") + }, + }) + }) +})