├── src ├── tools │ ├── search │ │ ├── index.ts │ │ └── tavily.ts │ ├── types.ts │ ├── finance │ │ ├── index.ts │ │ ├── estimates.ts │ │ ├── api.ts │ │ ├── segments.ts │ │ ├── news.ts │ │ ├── prices.ts │ │ ├── crypto.ts │ │ ├── metrics.ts │ │ ├── constants.ts │ │ ├── fundamentals.ts │ │ └── filings.ts │ └── index.ts ├── index.tsx ├── cli │ └── types.ts ├── utils │ ├── index.ts │ ├── config.ts │ ├── env.ts │ ├── message-history.ts │ └── context.ts ├── components │ ├── index.ts │ ├── StatusMessage.tsx │ ├── QueueDisplay.tsx │ ├── Input.tsx │ ├── Intro.tsx │ ├── AnswerBox.tsx │ ├── ModelSelector.tsx │ └── AgentProgressView.tsx ├── theme.ts ├── hooks │ ├── useQueryQueue.ts │ ├── useApiKey.ts │ └── useAgentExecution.ts ├── agent │ ├── index.ts │ ├── schemas.ts │ ├── prompts.ts │ └── agent.ts ├── model │ └── llm.ts └── cli.tsx ├── env.example ├── tsconfig.json ├── .gitignore ├── package.json ├── jest.config.js └── README.md /src/tools/search/index.ts: -------------------------------------------------------------------------------- 1 | export { tavilySearch } from './tavily.js'; 2 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | import React from 'react'; 3 | import { render } from 'ink'; 4 | import { config } from 'dotenv'; 5 | import { CLI } from './cli.js'; 6 | 7 | // Load environment variables 8 | config({ quiet: true }); 9 | 10 | // Render the CLI app 11 | render(); 12 | 13 | -------------------------------------------------------------------------------- /src/cli/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application state for the CLI 3 | */ 4 | export type AppState = 'idle' | 'running' | 'model_select'; 5 | 6 | /** 7 | * Generate a unique ID for turns 8 | */ 9 | export function generateId(): string { 10 | return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; 11 | } 12 | -------------------------------------------------------------------------------- /src/tools/types.ts: -------------------------------------------------------------------------------- 1 | export interface ToolResult { 2 | data: unknown; 3 | sourceUrls?: string[]; 4 | } 5 | 6 | export function formatToolResult(data: unknown, sourceUrls?: string[]): string { 7 | const result: ToolResult = { data }; 8 | if (sourceUrls?.length) { 9 | result.sourceUrls = sourceUrls; 10 | } 11 | return JSON.stringify(result); 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { loadConfig, saveConfig, getSetting, setSetting } from './config.js'; 2 | export { 3 | getApiKeyName, 4 | checkApiKeyExists, 5 | saveApiKeyToEnv, 6 | promptForApiKey, 7 | ensureApiKeyForModel, 8 | } from './env.js'; 9 | export { ToolContextManager } from './context.js'; 10 | export { MessageHistory } from './message-history.js'; 11 | 12 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Intro } from './Intro.js'; 2 | export { Input } from './Input.js'; 3 | export { AnswerBox, UserQuery } from './AnswerBox.js'; 4 | export { ModelSelector, MODELS } from './ModelSelector.js'; 5 | export { QueueDisplay } from './QueueDisplay.js'; 6 | export { StatusMessage } from './StatusMessage.js'; 7 | export { AgentProgressView, CurrentTurnViewV2 } from './AgentProgressView.js'; 8 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | primary: '#58A6FF', 3 | primaryLight: '#a5cfff', 4 | success: 'green', 5 | error: 'red', 6 | warning: 'yellow', 7 | muted: '#808080', 8 | mutedDark: '#303030', 9 | accent: 'cyan', 10 | highlight: 'magenta', 11 | white: '#ffffff', 12 | } as const; 13 | 14 | export const dimensions = { 15 | boxWidth: 80, 16 | introWidth: 50, 17 | } as const; 18 | 19 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # LLM API Keys 2 | OPENAI_API_KEY=your-api-key 3 | ANTHROPIC_API_KEY=your-api-key 4 | GOOGLE_API_KEY=your-api-key 5 | 6 | # Stock Market API Key 7 | FINANCIAL_DATASETS_API_KEY=your-api-key 8 | 9 | # Web Search API Key 10 | TAVILY_API_KEY=your-api-key 11 | 12 | # LangSmith 13 | LANGSMITH_API_KEY=your-api-key 14 | LANGSMITH_ENDPOINT=https://api.smith.langchain.com 15 | LANGSMITH_PROJECT=dexter 16 | LANGSMITH_TRACING=true -------------------------------------------------------------------------------- /src/components/StatusMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | 4 | interface StatusMessageProps { 5 | message: string | null; 6 | } 7 | 8 | /** 9 | * Displays a status message (dimmed text) 10 | */ 11 | export function StatusMessage({ message }: StatusMessageProps) { 12 | if (!message) { 13 | return null; 14 | } 15 | 16 | return ( 17 | 18 | {message} 19 | 20 | ); 21 | } 22 | 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "jsx": "react-jsx", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "outDir": "./dist", 15 | "rootDir": "./src", 16 | "baseUrl": ".", 17 | "paths": { 18 | "@/*": ["./src/*"] 19 | } 20 | }, 21 | "include": ["src/**/*"], 22 | "exclude": ["node_modules", "dist"] 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/tools/finance/index.ts: -------------------------------------------------------------------------------- 1 | export { getIncomeStatements, getBalanceSheets, getCashFlowStatements, getAllFinancialStatements } from './fundamentals.js'; 2 | export { getFilings, get10KFilingItems, get10QFilingItems, get8KFilingItems } from './filings.js'; 3 | export { getPriceSnapshot, getPrices } from './prices.js'; 4 | export { getFinancialMetricsSnapshot, getFinancialMetrics } from './metrics.js'; 5 | export { getNews } from './news.js'; 6 | export { getAnalystEstimates } from './estimates.js'; 7 | export { getSegmentedRevenues } from './segments.js'; 8 | export { getCryptoPriceSnapshot, getCryptoPrices, getCryptoTickers } from './crypto.js'; 9 | 10 | -------------------------------------------------------------------------------- /src/components/QueueDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import { colors } from '../theme.js'; 4 | 5 | interface QueueDisplayProps { 6 | queries: string[]; 7 | } 8 | 9 | export function QueueDisplay({ queries }: QueueDisplayProps) { 10 | if (queries.length === 0) return null; 11 | 12 | return ( 13 | 14 | Queued ({queries.length}): 15 | {queries.map((q, i) => ( 16 | 17 | {' '}{i + 1}. {q.length > 60 ? q.slice(0, 57) + '...' : q} 18 | 19 | ))} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useQueryQueue.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | interface UseQueryQueueResult { 4 | queue: string[]; 5 | enqueue: (query: string) => void; 6 | shift: () => void; 7 | clear: () => void; 8 | } 9 | 10 | export function useQueryQueue(): UseQueryQueueResult { 11 | const [queue, setQueue] = useState([]); 12 | 13 | const enqueue = useCallback((query: string) => { 14 | setQueue(prev => [...prev, query]); 15 | }, []); 16 | 17 | const shift = useCallback(() => { 18 | setQueue(prev => prev.slice(1)); 19 | }, []); 20 | 21 | const clear = useCallback(() => setQueue([]), []); 22 | 23 | return { queue, enqueue, shift, clear }; 24 | } 25 | -------------------------------------------------------------------------------- /src/agent/index.ts: -------------------------------------------------------------------------------- 1 | // Main Agent class and types 2 | export { Agent, AgentCallbacks, AgentOptions, ToolCallInfo, ToolCallResult } from './agent.js'; 3 | 4 | // Schemas and types 5 | export { 6 | ThinkingStep, 7 | ToolCallStep, 8 | Iteration, 9 | AgentState, 10 | ToolSummary, 11 | FinishToolSchema, 12 | FinishToolArgs, 13 | ThinkingSchema, 14 | Thinking, 15 | SelectedContextsSchema, 16 | SelectedContexts, 17 | } from './schemas.js'; 18 | 19 | // Prompts (shared utilities) 20 | export { 21 | DEFAULT_SYSTEM_PROMPT, 22 | getCurrentDate, 23 | getAnswerSystemPrompt, 24 | getSystemPrompt, 25 | formatToolSummaries, 26 | buildUserPrompt, 27 | CONTEXT_SELECTION_SYSTEM_PROMPT, 28 | } from './prompts.js'; 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env 3 | .env.local 4 | .env.* 5 | 6 | # Python 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | *.so 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # Virtual environments 29 | venv/ 30 | env/ 31 | ENV/ 32 | .venv 33 | 34 | # Node.js 35 | node_modules/ 36 | npm-debug.log* 37 | yarn-debug.log* 38 | yarn-error.log* 39 | 40 | # IDEs 41 | .vscode/ 42 | .idea/ 43 | *.swp 44 | *.swo 45 | *~ 46 | .DS_Store 47 | 48 | # UV 49 | .uv/ 50 | 51 | # Cursor files 52 | .cursor/ 53 | 54 | # Dexter context files (offloaded tool outputs) 55 | .dexter/context/ 56 | .dexter/settings.json 57 | -------------------------------------------------------------------------------- /src/hooks/useApiKey.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { ensureApiKeyForModel } from '../utils/env.js'; 3 | 4 | interface UseApiKeyResult { 5 | apiKeyReady: boolean; 6 | } 7 | 8 | /** 9 | * Hook to check and ensure API key is available for the given model 10 | */ 11 | export function useApiKey(model: string): UseApiKeyResult { 12 | const [apiKeyReady, setApiKeyReady] = useState(false); 13 | 14 | useEffect(() => { 15 | const checkApiKey = async () => { 16 | const ready = await ensureApiKeyForModel(model); 17 | setApiKeyReady(ready); 18 | if (!ready) { 19 | console.error(`Cannot start without API key for ${model}`); 20 | } 21 | }; 22 | checkApiKey(); 23 | }, [model]); 24 | 25 | return { apiKeyReady }; 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/tools/search/tavily.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { TavilySearch } from '@langchain/tavily'; 3 | import { z } from 'zod'; 4 | import { formatToolResult } from '../types.js'; 5 | 6 | const tavilyClient = new TavilySearch({ maxResults: 5 }); 7 | 8 | export const tavilySearch = new DynamicStructuredTool({ 9 | name: 'search_web', 10 | description: 'Search the web for current information on any topic. Returns relevant search results with URLs and content snippets.', 11 | schema: z.object({ 12 | query: z.string().describe('The search query to look up on the web'), 13 | }), 14 | func: async (input) => { 15 | const result = await tavilyClient.invoke({ query: input.query }); 16 | const parsed = typeof result === 'string' ? JSON.parse(result) : result; 17 | const urls = parsed.results 18 | ?.map((r: { url?: string }) => r.url) 19 | .filter((url: string | undefined): url is string => Boolean(url)) ?? []; 20 | return formatToolResult(parsed, urls); 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import TextInput from 'ink-text-input'; 4 | 5 | import { colors } from '../theme.js'; 6 | 7 | interface InputProps { 8 | onSubmit: (value: string) => void; 9 | } 10 | 11 | export function Input({ onSubmit }: InputProps) { 12 | // Input manages its own state - typing won't cause parent re-renders 13 | const [value, setValue] = useState(''); 14 | 15 | const handleSubmit = (val: string) => { 16 | if (!val.trim()) return; 17 | onSubmit(val); 18 | setValue(''); 19 | }; 20 | 21 | return ( 22 | 31 | 32 | 33 | {'> '} 34 | 35 | 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; 2 | import { dirname, join } from 'path'; 3 | 4 | const SETTINGS_FILE = '.dexter/settings.json'; 5 | 6 | interface Config { 7 | model?: string; 8 | [key: string]: unknown; 9 | } 10 | 11 | export function loadConfig(): Config { 12 | if (!existsSync(SETTINGS_FILE)) { 13 | return {}; 14 | } 15 | 16 | try { 17 | const content = readFileSync(SETTINGS_FILE, 'utf-8'); 18 | return JSON.parse(content); 19 | } catch { 20 | return {}; 21 | } 22 | } 23 | 24 | export function saveConfig(config: Config): boolean { 25 | try { 26 | const dir = dirname(SETTINGS_FILE); 27 | if (!existsSync(dir)) { 28 | mkdirSync(dir, { recursive: true }); 29 | } 30 | writeFileSync(SETTINGS_FILE, JSON.stringify(config, null, 2)); 31 | return true; 32 | } catch { 33 | return false; 34 | } 35 | } 36 | 37 | export function getSetting(key: string, defaultValue: T): T { 38 | const config = loadConfig(); 39 | return (config[key] as T) ?? defaultValue; 40 | } 41 | 42 | export function setSetting(key: string, value: unknown): boolean { 43 | const config = loadConfig(); 44 | config[key] = value; 45 | return saveConfig(config); 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/tools/finance/estimates.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { z } from 'zod'; 3 | import { callApi } from './api.js'; 4 | import { formatToolResult } from '../types.js'; 5 | 6 | const AnalystEstimatesInputSchema = z.object({ 7 | ticker: z 8 | .string() 9 | .describe( 10 | "The stock ticker symbol to fetch analyst estimates for. For example, 'AAPL' for Apple." 11 | ), 12 | period: z 13 | .enum(['annual', 'quarterly']) 14 | .default('annual') 15 | .describe("The period for the estimates, either 'annual' or 'quarterly'."), 16 | }); 17 | 18 | export const getAnalystEstimates = new DynamicStructuredTool({ 19 | name: 'get_analyst_estimates', 20 | description: `Retrieves analyst estimates for a given company ticker, including metrics like estimated EPS. Useful for understanding consensus expectations, assessing future growth prospects, and performing valuation analysis.`, 21 | schema: AnalystEstimatesInputSchema, 22 | func: async (input) => { 23 | const params = { 24 | ticker: input.ticker, 25 | period: input.period, 26 | }; 27 | const { data, url } = await callApi('/analyst-estimates/', params); 28 | return formatToolResult(data.analyst_estimates || [], [url]); 29 | }, 30 | }); 31 | 32 | -------------------------------------------------------------------------------- /src/tools/finance/api.ts: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'https://api.financialdatasets.ai'; 2 | 3 | export interface ApiResponse { 4 | data: Record; 5 | url: string; 6 | } 7 | 8 | export async function callApi( 9 | endpoint: string, 10 | params: Record 11 | ): Promise { 12 | // Read API key lazily at call time (after dotenv has loaded) 13 | const FINANCIAL_DATASETS_API_KEY = process.env.FINANCIAL_DATASETS_API_KEY; 14 | const url = new URL(`${BASE_URL}${endpoint}`); 15 | 16 | // Add params to URL, handling arrays 17 | for (const [key, value] of Object.entries(params)) { 18 | if (value !== undefined && value !== null) { 19 | if (Array.isArray(value)) { 20 | value.forEach((v) => url.searchParams.append(key, v)); 21 | } else { 22 | url.searchParams.append(key, String(value)); 23 | } 24 | } 25 | } 26 | 27 | const response = await fetch(url.toString(), { 28 | headers: { 29 | 'x-api-key': FINANCIAL_DATASETS_API_KEY || '', 30 | }, 31 | }); 32 | 33 | if (!response.ok) { 34 | throw new Error(`API request failed: ${response.status} ${response.statusText}`); 35 | } 36 | 37 | const data = await response.json(); 38 | return { data, url: url.toString() }; 39 | } 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dexter-ts", 3 | "version": "2.0.2", 4 | "description": "Dexter - AI agent for deep financial research.", 5 | "type": "module", 6 | "main": "src/index.tsx", 7 | "bin": { 8 | "dexter-ts": "./src/index.tsx" 9 | }, 10 | "scripts": { 11 | "start": "bun run src/index.tsx", 12 | "dev": "bun --watch run src/index.tsx", 13 | "typecheck": "tsc --noEmit", 14 | "test": "bun test", 15 | "test:watch": "bun test --watch" 16 | }, 17 | "dependencies": { 18 | "@langchain/anthropic": "^1.1.3", 19 | "@langchain/community": "^1.0.7", 20 | "@langchain/core": "^1.1.0", 21 | "@langchain/google-genai": "^2.0.0", 22 | "@langchain/openai": "^1.1.3", 23 | "@langchain/tavily": "^1.0.1", 24 | "dotenv": "^17.2.3", 25 | "ink": "^6.5.1", 26 | "ink-spinner": "^5.0.0", 27 | "ink-text-input": "^6.0.0", 28 | "react": "^19.2.0", 29 | "zod": "^4.1.13", 30 | "zod-to-json-schema": "^3.25.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.28.5", 34 | "@babel/preset-env": "^7.28.5", 35 | "@types/bun": "latest", 36 | "@types/jest": "^29.5.14", 37 | "@types/react": "^19.2.7", 38 | "babel-jest": "^30.2.0", 39 | "jest": "^29.7.0", 40 | "ts-jest": "^29.2.5", 41 | "typescript": "^5.9.3" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | testEnvironment: 'node', 5 | extensionsToTreatAsEsm: ['.ts', '.tsx'], 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | transform: { 10 | // Transform TypeScript files with ts-jest 11 | '^.+\\.tsx?$': [ 12 | 'ts-jest', 13 | { 14 | useESM: true, 15 | tsconfig: 'tsconfig.json', 16 | }, 17 | ], 18 | // Transform ESM JavaScript packages from node_modules with babel-jest 19 | 'node_modules/(p-retry|is-network-error|@langchain)/.+\\.js$': [ 20 | 'babel-jest', 21 | { 22 | presets: [['@babel/preset-env', { targets: { node: 'current' } }]], 23 | }, 24 | ], 25 | }, 26 | // Transform ESM packages from node_modules that Jest can't handle natively 27 | transformIgnorePatterns: [ 28 | 'node_modules/(?!(p-retry|is-network-error|@langchain)/)', 29 | ], 30 | testMatch: ['**/__tests__/**/*.test.ts'], 31 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 32 | collectCoverageFrom: [ 33 | 'src/agent/**/*.ts', 34 | '!src/agent/**/*.test.ts', 35 | '!src/agent/__tests__/**', 36 | '!src/agent/index.ts', 37 | ], 38 | coverageDirectory: 'coverage', 39 | verbose: true, 40 | }; 41 | 42 | -------------------------------------------------------------------------------- /src/tools/finance/segments.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { z } from 'zod'; 3 | import { callApi } from './api.js'; 4 | import { formatToolResult } from '../types.js'; 5 | 6 | const SegmentedRevenuesInputSchema = z.object({ 7 | ticker: z 8 | .string() 9 | .describe( 10 | "The stock ticker symbol to fetch segmented revenues for. For example, 'AAPL' for Apple." 11 | ), 12 | period: z 13 | .enum(['annual', 'quarterly']) 14 | .describe( 15 | "The reporting period for the segmented revenues. 'annual' for yearly, 'quarterly' for quarterly." 16 | ), 17 | limit: z.number().default(10).describe('The number of past periods to retrieve.'), 18 | }); 19 | 20 | export const getSegmentedRevenues = new DynamicStructuredTool({ 21 | name: 'get_segmented_revenues', 22 | description: `Provides a detailed breakdown of a company's revenue by operating segments, such as products, services, or geographic regions. Useful for analyzing the composition of a company's revenue.`, 23 | schema: SegmentedRevenuesInputSchema, 24 | func: async (input) => { 25 | const params = { 26 | ticker: input.ticker, 27 | period: input.period, 28 | limit: input.limit, 29 | }; 30 | const { data, url } = await callApi('/financials/segmented-revenues/', params); 31 | return formatToolResult(data.segmented_revenues || {}, [url]); 32 | }, 33 | }); 34 | 35 | -------------------------------------------------------------------------------- /src/tools/finance/news.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { z } from 'zod'; 3 | import { callApi } from './api.js'; 4 | import { formatToolResult } from '../types.js'; 5 | 6 | const NewsInputSchema = z.object({ 7 | ticker: z 8 | .string() 9 | .describe("The stock ticker symbol to fetch news for. For example, 'AAPL' for Apple."), 10 | start_date: z 11 | .string() 12 | .optional() 13 | .describe('The start date to fetch news from (YYYY-MM-DD).'), 14 | end_date: z.string().optional().describe('The end date to fetch news to (YYYY-MM-DD).'), 15 | limit: z 16 | .number() 17 | .default(10) 18 | .describe('The number of news articles to retrieve. Max is 100.'), 19 | }); 20 | 21 | export const getNews = new DynamicStructuredTool({ 22 | name: 'get_news', 23 | description: `Retrieves recent news articles for a given company ticker, covering financial announcements, market trends, and other significant events. Useful for staying up-to-date with market-moving information and investor sentiment.`, 24 | schema: NewsInputSchema, 25 | func: async (input) => { 26 | const params: Record = { 27 | ticker: input.ticker, 28 | limit: input.limit, 29 | start_date: input.start_date, 30 | end_date: input.end_date, 31 | }; 32 | const { data, url } = await callApi('/news/', params); 33 | return formatToolResult(data.news || [], [url]); 34 | }, 35 | }); 36 | 37 | -------------------------------------------------------------------------------- /src/components/Intro.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import { colors, dimensions } from '../theme.js'; 4 | import packageJson from '../../package.json'; 5 | 6 | export function Intro() { 7 | const { introWidth } = dimensions; 8 | const welcomeText = 'Welcome to Dexter'; 9 | const versionText = ` v${packageJson.version}`; 10 | const fullText = welcomeText + versionText; 11 | const padding = Math.floor((introWidth - fullText.length - 2) / 2); 12 | 13 | return ( 14 | 15 | {'═'.repeat(introWidth)} 16 | 17 | ║{' '.repeat(padding)} 18 | {welcomeText} 19 | {versionText} 20 | {' '.repeat(introWidth - fullText.length - padding - 2)}║ 21 | 22 | {'═'.repeat(introWidth)} 23 | 24 | 25 | 26 | {` 27 | ██████╗ ███████╗██╗ ██╗████████╗███████╗██████╗ 28 | ██╔══██╗██╔════╝╚██╗██╔╝╚══██╔══╝██╔════╝██╔══██╗ 29 | ██║ ██║█████╗ ╚███╔╝ ██║ █████╗ ██████╔╝ 30 | ██║ ██║██╔══╝ ██╔██╗ ██║ ██╔══╝ ██╔══██╗ 31 | ██████╔╝███████╗██╔╝ ██╗ ██║ ███████╗██║ ██║ 32 | ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝`} 33 | 34 | 35 | 36 | 37 | Your AI assistant for deep financial research. 38 | Press Ctrl+C to quit. Type /model to change the model. 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | import { StructuredToolInterface } from '@langchain/core/tools'; 2 | import { 3 | getIncomeStatements, 4 | getBalanceSheets, 5 | getCashFlowStatements, 6 | getAllFinancialStatements, 7 | getFilings, 8 | get10KFilingItems, 9 | get10QFilingItems, 10 | get8KFilingItems, 11 | getPriceSnapshot, 12 | getPrices, 13 | getFinancialMetricsSnapshot, 14 | getFinancialMetrics, 15 | getNews, 16 | getAnalystEstimates, 17 | getSegmentedRevenues, 18 | getCryptoPriceSnapshot, 19 | getCryptoPrices, 20 | getCryptoTickers, 21 | } from './finance/index.js'; 22 | import { tavilySearch } from './search/index.js'; 23 | 24 | export const TOOLS: StructuredToolInterface[] = [ 25 | getIncomeStatements, 26 | getBalanceSheets, 27 | getCashFlowStatements, 28 | getAllFinancialStatements, 29 | get10KFilingItems, 30 | get10QFilingItems, 31 | get8KFilingItems, 32 | getFilings, 33 | getPriceSnapshot, 34 | getPrices, 35 | getCryptoPriceSnapshot, 36 | getCryptoPrices, 37 | getCryptoTickers, 38 | getFinancialMetricsSnapshot, 39 | getFinancialMetrics, 40 | getNews, 41 | getAnalystEstimates, 42 | getSegmentedRevenues, 43 | ...(process.env.TAVILY_API_KEY ? [tavilySearch] : []), 44 | ]; 45 | 46 | export { 47 | getIncomeStatements, 48 | getBalanceSheets, 49 | getCashFlowStatements, 50 | getAllFinancialStatements, 51 | getFilings, 52 | get10KFilingItems, 53 | get10QFilingItems, 54 | get8KFilingItems, 55 | getPriceSnapshot, 56 | getPrices, 57 | getCryptoPriceSnapshot, 58 | getCryptoPrices, 59 | getCryptoTickers, 60 | getFinancialMetricsSnapshot, 61 | getFinancialMetrics, 62 | getNews, 63 | getAnalystEstimates, 64 | getSegmentedRevenues, 65 | tavilySearch, 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/AnswerBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import { colors } from '../theme.js'; 4 | 5 | interface AnswerBoxProps { 6 | stream?: AsyncGenerator; 7 | text?: string; 8 | onStart?: () => void; 9 | onComplete?: (answer: string) => void; 10 | } 11 | 12 | export const AnswerBox = React.memo(function AnswerBox({ stream, text, onStart, onComplete }: AnswerBoxProps) { 13 | const [content, setContent] = useState(text || ''); 14 | const [isStreaming, setIsStreaming] = useState(!!stream); 15 | 16 | // Store callbacks in refs to avoid effect re-runs when references change 17 | const onStartRef = useRef(onStart); 18 | const onCompleteRef = useRef(onComplete); 19 | onStartRef.current = onStart; 20 | onCompleteRef.current = onComplete; 21 | 22 | useEffect(() => { 23 | if (!stream) return; 24 | 25 | let collected = text || ''; 26 | let started = false; 27 | 28 | (async () => { 29 | try { 30 | for await (const chunk of stream) { 31 | if (!started && chunk.trim()) { 32 | started = true; 33 | onStartRef.current?.(); 34 | } 35 | collected += chunk; 36 | setContent(collected); 37 | } 38 | } finally { 39 | setIsStreaming(false); 40 | onCompleteRef.current?.(collected); 41 | } 42 | })(); 43 | }, [stream, text]); 44 | 45 | return ( 46 | 47 | 48 | {content} 49 | {isStreaming && '▌'} 50 | 51 | 52 | ); 53 | }); 54 | 55 | interface UserQueryProps { 56 | query: string; 57 | } 58 | 59 | export function UserQuery({ query }: UserQueryProps) { 60 | return ( 61 | 62 | 63 | {'>'} {query}{' '} 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/tools/finance/prices.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { z } from 'zod'; 3 | import { callApi } from './api.js'; 4 | import { formatToolResult } from '../types.js'; 5 | 6 | const PriceSnapshotInputSchema = z.object({ 7 | ticker: z 8 | .string() 9 | .describe( 10 | "The stock ticker symbol to fetch the price snapshot for. For example, 'AAPL' for Apple." 11 | ), 12 | }); 13 | 14 | export const getPriceSnapshot = new DynamicStructuredTool({ 15 | name: 'get_price_snapshot', 16 | description: `Fetches the most recent price snapshot for a specific stock ticker, including the latest price, trading volume, and other open, high, low, and close price data.`, 17 | schema: PriceSnapshotInputSchema, 18 | func: async (input) => { 19 | const params = { ticker: input.ticker }; 20 | const { data, url } = await callApi('/prices/snapshot/', params); 21 | return formatToolResult(data.snapshot || {}, [url]); 22 | }, 23 | }); 24 | 25 | const PricesInputSchema = z.object({ 26 | ticker: z 27 | .string() 28 | .describe( 29 | "The stock ticker symbol to fetch aggregated prices for. For example, 'AAPL' for Apple." 30 | ), 31 | interval: z 32 | .enum(['minute', 'day', 'week', 'month', 'year']) 33 | .default('day') 34 | .describe("The time interval for price data. Defaults to 'day'."), 35 | interval_multiplier: z 36 | .number() 37 | .default(1) 38 | .describe('Multiplier for the interval. Defaults to 1.'), 39 | start_date: z.string().describe('Start date in YYYY-MM-DD format. Required.'), 40 | end_date: z.string().describe('End date in YYYY-MM-DD format. Required.'), 41 | }); 42 | 43 | export const getPrices = new DynamicStructuredTool({ 44 | name: 'get_prices', 45 | description: `Retrieves historical price data for a stock over a specified date range, including open, high, low, close prices, and volume.`, 46 | schema: PricesInputSchema, 47 | func: async (input) => { 48 | const params = { 49 | ticker: input.ticker, 50 | interval: input.interval, 51 | interval_multiplier: input.interval_multiplier, 52 | start_date: input.start_date, 53 | end_date: input.end_date, 54 | }; 55 | const { data, url } = await callApi('/prices/', params); 56 | return formatToolResult(data.prices || [], [url]); 57 | }, 58 | }); 59 | 60 | -------------------------------------------------------------------------------- /src/components/ModelSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Box, Text, useInput } from 'ink'; 3 | import { colors } from '../theme.js'; 4 | 5 | interface Model { 6 | displayName: string; 7 | modelId: string; 8 | description: string; 9 | } 10 | 11 | const MODELS: Model[] = [ 12 | { 13 | displayName: 'GPT 5.2', 14 | modelId: 'gpt-5.2', 15 | description: "OpenAI's flagship model", 16 | }, 17 | { 18 | displayName: 'Sonnet 4.5', 19 | modelId: 'claude-sonnet-4-5', 20 | description: "Anthropic's best model for complex agents", 21 | }, 22 | { 23 | displayName: 'Gemini 3', 24 | modelId: 'gemini-3', 25 | description: "Google's most intelligent model", 26 | }, 27 | ]; 28 | 29 | interface ModelSelectorProps { 30 | model?: string; 31 | onSelect: (modelId: string | null) => void; 32 | } 33 | 34 | export function ModelSelector({ model, onSelect }: ModelSelectorProps) { 35 | const [selectedIndex, setSelectedIndex] = useState(() => { 36 | if (model) { 37 | const idx = MODELS.findIndex((m) => m.modelId === model); 38 | return idx >= 0 ? idx : 0; 39 | } 40 | return 0; 41 | }); 42 | 43 | useInput((input, key) => { 44 | if (key.upArrow || input === 'k') { 45 | setSelectedIndex((prev) => Math.max(0, prev - 1)); 46 | } else if (key.downArrow || input === 'j') { 47 | setSelectedIndex((prev) => Math.min(MODELS.length - 1, prev + 1)); 48 | } else if (key.return) { 49 | onSelect(MODELS[selectedIndex].modelId); 50 | } else if (key.escape) { 51 | onSelect(null); 52 | } 53 | }); 54 | 55 | return ( 56 | 57 | 58 | Select model 59 | 60 | 61 | Switch between LLM models. Applies to this session and future sessions. 62 | 63 | 64 | {MODELS.map((m, idx) => { 65 | const isSelected = idx === selectedIndex; 66 | const isCurrent = model === m.modelId; 67 | const prefix = isSelected ? '> ' : ' '; 68 | 69 | return ( 70 | 75 | {prefix} 76 | {idx + 1}. {m.displayName} · {m.description} 77 | {isCurrent ? ' ✓' : ''} 78 | 79 | ); 80 | })} 81 | 82 | 83 | Enter to confirm · Esc to exit 84 | 85 | 86 | ); 87 | } 88 | 89 | export { MODELS }; 90 | -------------------------------------------------------------------------------- /src/agent/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // ============================================================================ 4 | // Agent Loop Types 5 | // ============================================================================ 6 | 7 | /** 8 | * A thinking step in the agent loop - the agent's reasoning 9 | */ 10 | export interface ThinkingStep { 11 | thought: string; 12 | } 13 | 14 | /** 15 | * A tool call step with its execution status 16 | */ 17 | export interface ToolCallStep { 18 | toolName: string; 19 | args: Record; 20 | summary: string; 21 | status: 'pending' | 'running' | 'completed' | 'failed'; 22 | } 23 | 24 | /** 25 | * A single iteration in the agent loop 26 | */ 27 | export interface Iteration { 28 | id: number; 29 | thinking: ThinkingStep | null; 30 | toolCalls: ToolCallStep[]; 31 | status: 'thinking' | 'acting' | 'completed'; 32 | } 33 | 34 | /** 35 | * Overall state of the agent for UI display 36 | */ 37 | export interface AgentState { 38 | iterations: Iteration[]; 39 | currentIteration: number; 40 | status: 'reasoning' | 'executing' | 'answering' | 'done'; 41 | } 42 | 43 | // ============================================================================ 44 | // Tool Summary Types 45 | // ============================================================================ 46 | 47 | /** 48 | * Lightweight summary of a tool call result (kept in context during loop) 49 | */ 50 | export interface ToolSummary { 51 | id: string; // Filepath pointer to full data on disk 52 | toolName: string; 53 | args: Record; 54 | summary: string; // Deterministic description 55 | } 56 | 57 | // ============================================================================ 58 | // LLM Response Schemas 59 | // ============================================================================ 60 | 61 | /** 62 | * Schema for the "finish" tool that signals the agent is ready to answer. 63 | * Using a tool for this is cleaner with LangChain's tool binding. 64 | */ 65 | export const FinishToolSchema = z.object({ 66 | reason: z.string().describe('Brief explanation of why you have enough data to answer'), 67 | }); 68 | 69 | export type FinishToolArgs = z.infer; 70 | 71 | /** 72 | * Schema for extracting the agent's thinking from its response. 73 | * The thought explains the reasoning before tool calls. 74 | */ 75 | export const ThinkingSchema = z.object({ 76 | thought: z.string().describe('Your reasoning about what data you need or why you are ready to answer'), 77 | }); 78 | 79 | export type Thinking = z.infer; 80 | 81 | // ============================================================================ 82 | // Context Selection Schema (used by utils/context.ts) 83 | // ============================================================================ 84 | 85 | export const SelectedContextsSchema = z.object({ 86 | context_ids: z 87 | .array(z.number()) 88 | .describe( 89 | 'List of context pointer IDs (0-indexed) that are relevant for answering the query.' 90 | ), 91 | }); 92 | 93 | export type SelectedContexts = z.infer; 94 | -------------------------------------------------------------------------------- /src/tools/finance/crypto.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { z } from 'zod'; 3 | import { callApi } from './api.js'; 4 | import { formatToolResult } from '../types.js'; 5 | 6 | const CryptoPriceSnapshotInputSchema = z.object({ 7 | ticker: z 8 | .string() 9 | .describe( 10 | "The crypto ticker symbol to fetch the price snapshot for. For example, 'BTC-USD' for Bitcoin." 11 | ), 12 | }); 13 | 14 | export const getCryptoPriceSnapshot = new DynamicStructuredTool({ 15 | name: 'get_crypto_price_snapshot', 16 | description: `Fetches the most recent price snapshot for a specific cryptocurrency, including the latest price, trading volume, and other open, high, low, and close price data. Ticker format: use 'CRYPTO-USD' for USD prices (e.g., 'BTC-USD') or 'CRYPTO-CRYPTO' for crypto-to-crypto prices (e.g., 'BTC-ETH' for Bitcoin priced in Ethereum).`, 17 | schema: CryptoPriceSnapshotInputSchema, 18 | func: async (input) => { 19 | const params = { ticker: input.ticker }; 20 | const { data, url } = await callApi('/crypto/prices/snapshot/', params); 21 | return formatToolResult(data.snapshot || {}, [url]); 22 | }, 23 | }); 24 | 25 | const CryptoPricesInputSchema = z.object({ 26 | ticker: z 27 | .string() 28 | .describe( 29 | "The crypto ticker symbol to fetch aggregated prices for. For example, 'BTC-USD' for Bitcoin." 30 | ), 31 | interval: z 32 | .enum(['minute', 'day', 'week', 'month', 'year']) 33 | .default('day') 34 | .describe("The time interval for price data. Defaults to 'day'."), 35 | interval_multiplier: z 36 | .number() 37 | .default(1) 38 | .describe('Multiplier for the interval. Defaults to 1.'), 39 | start_date: z.string().describe('Start date in YYYY-MM-DD format. Required.'), 40 | end_date: z.string().describe('End date in YYYY-MM-DD format. Required.'), 41 | }); 42 | 43 | export const getCryptoPrices = new DynamicStructuredTool({ 44 | name: 'get_crypto_prices', 45 | description: `Retrieves historical price data for a cryptocurrency over a specified date range, including open, high, low, close prices, and volume. Ticker format: use 'CRYPTO-USD' for USD prices (e.g., 'BTC-USD') or 'CRYPTO-CRYPTO' for crypto-to-crypto prices (e.g., 'BTC-ETH' for Bitcoin priced in Ethereum).`, 46 | schema: CryptoPricesInputSchema, 47 | func: async (input) => { 48 | const params = { 49 | ticker: input.ticker, 50 | interval: input.interval, 51 | interval_multiplier: input.interval_multiplier, 52 | start_date: input.start_date, 53 | end_date: input.end_date, 54 | }; 55 | const { data, url } = await callApi('/crypto/prices/', params); 56 | return formatToolResult(data.prices || [], [url]); 57 | }, 58 | }); 59 | 60 | export const getCryptoTickers = new DynamicStructuredTool({ 61 | name: 'get_available_crypto_tickers', 62 | description: `Retrieves the list of available cryptocurrency tickers that can be used with the crypto price tools.`, 63 | schema: z.object({}), 64 | func: async () => { 65 | const { data, url } = await callApi('/crypto/prices/tickers/', {}); 66 | return formatToolResult(data.tickers || [], [url]); 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /src/tools/finance/metrics.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { z } from 'zod'; 3 | import { callApi } from './api.js'; 4 | import { formatToolResult } from '../types.js'; 5 | 6 | const FinancialMetricsSnapshotInputSchema = z.object({ 7 | ticker: z 8 | .string() 9 | .describe( 10 | "The stock ticker symbol to fetch financial metrics snapshot for. For example, 'AAPL' for Apple." 11 | ), 12 | }); 13 | 14 | export const getFinancialMetricsSnapshot = new DynamicStructuredTool({ 15 | name: 'get_financial_metrics_snapshot', 16 | description: `Fetches a snapshot of the most current financial metrics for a company, including key indicators like market capitalization, P/E ratio, and dividend yield. Useful for a quick overview of a company's financial health.`, 17 | schema: FinancialMetricsSnapshotInputSchema, 18 | func: async (input) => { 19 | const params = { ticker: input.ticker }; 20 | const { data, url } = await callApi('/financial-metrics/snapshot/', params); 21 | return formatToolResult(data.snapshot || {}, [url]); 22 | }, 23 | }); 24 | 25 | const FinancialMetricsInputSchema = z.object({ 26 | ticker: z 27 | .string() 28 | .describe( 29 | "The stock ticker symbol to fetch financial metrics for. For example, 'AAPL' for Apple." 30 | ), 31 | period: z 32 | .enum(['annual', 'quarterly', 'ttm']) 33 | .default('ttm') 34 | .describe( 35 | "The reporting period. 'annual' for yearly, 'quarterly' for quarterly, and 'ttm' for trailing twelve months." 36 | ), 37 | limit: z 38 | .number() 39 | .default(4) 40 | .describe('The number of past financial statements to retrieve.'), 41 | report_period: z 42 | .string() 43 | .optional() 44 | .describe('Filter for financial metrics with an exact report period date (YYYY-MM-DD).'), 45 | report_period_gt: z 46 | .string() 47 | .optional() 48 | .describe('Filter for financial metrics with report periods after this date (YYYY-MM-DD).'), 49 | report_period_gte: z 50 | .string() 51 | .optional() 52 | .describe( 53 | 'Filter for financial metrics with report periods on or after this date (YYYY-MM-DD).' 54 | ), 55 | report_period_lt: z 56 | .string() 57 | .optional() 58 | .describe('Filter for financial metrics with report periods before this date (YYYY-MM-DD).'), 59 | report_period_lte: z 60 | .string() 61 | .optional() 62 | .describe( 63 | 'Filter for financial metrics with report periods on or before this date (YYYY-MM-DD).' 64 | ), 65 | }); 66 | 67 | export const getFinancialMetrics = new DynamicStructuredTool({ 68 | name: 'get_financial_metrics', 69 | description: `Retrieves historical financial metrics for a company, such as P/E ratio, revenue per share, and enterprise value, over a specified period. Useful for trend analysis and historical performance evaluation.`, 70 | schema: FinancialMetricsInputSchema, 71 | func: async (input) => { 72 | const params: Record = { 73 | ticker: input.ticker, 74 | period: input.period, 75 | limit: input.limit, 76 | report_period: input.report_period, 77 | report_period_gt: input.report_period_gt, 78 | report_period_gte: input.report_period_gte, 79 | report_period_lt: input.report_period_lt, 80 | report_period_lte: input.report_period_lte, 81 | }; 82 | const { data, url } = await callApi('/financial-metrics/', params); 83 | return formatToolResult(data.financial_metrics || [], [url]); 84 | }, 85 | }); 86 | 87 | -------------------------------------------------------------------------------- /src/components/AgentProgressView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import InkSpinner from 'ink-spinner'; 4 | import { colors } from '../theme.js'; 5 | import type { AgentState, Iteration } from '../agent/schemas.js'; 6 | 7 | // ============================================================================ 8 | // Helper Components 9 | // ============================================================================ 10 | 11 | /** 12 | * Status icon - dots spinner when active, checkmark when complete 13 | */ 14 | function StatusIcon({ active }: { active: boolean }) { 15 | if (active) { 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | return ; 23 | } 24 | 25 | // ============================================================================ 26 | // Iteration View (Claude Code style) 27 | // ============================================================================ 28 | 29 | interface IterationViewProps { 30 | iteration: Iteration; 31 | isActive: boolean; 32 | } 33 | 34 | const IterationView = React.memo(function IterationView({ iteration, isActive }: IterationViewProps) { 35 | if (!iteration.thinking) { 36 | return ( 37 | 38 | 39 | Thinking... 40 | 41 | ); 42 | } 43 | 44 | return ( 45 | 46 | 47 | 48 | {iteration.thinking.thought} 49 | 50 | ); 51 | }); 52 | 53 | // ============================================================================ 54 | // Main Component 55 | // ============================================================================ 56 | 57 | interface AgentProgressViewProps { 58 | state: AgentState; 59 | } 60 | 61 | /** 62 | * Displays the agent's progress in Claude Code style. 63 | * Shows tasks with progress circles and nested tool calls. 64 | */ 65 | export const AgentProgressView = React.memo(function AgentProgressView({ state }: AgentProgressViewProps) { 66 | if (state.iterations.length === 0) { 67 | return ( 68 | 69 | 70 | Starting... 71 | 72 | ); 73 | } 74 | 75 | const isAnswering = state.status === 'answering'; 76 | const isDone = state.status === 'done'; 77 | const allComplete = isAnswering || isDone; 78 | 79 | // When answering/done, only show iterations that have thinking content 80 | const visibleIterations = allComplete 81 | ? state.iterations.filter(it => it.thinking) 82 | : state.iterations; 83 | 84 | return ( 85 | 86 | {visibleIterations.map((iteration) => ( 87 | 92 | ))} 93 | {isAnswering && ( 94 | 95 | 96 | Generating answer... 97 | 98 | )} 99 | 100 | ); 101 | }); 102 | 103 | // ============================================================================ 104 | // Current Turn View V2 105 | // ============================================================================ 106 | 107 | interface CurrentTurnViewV2Props { 108 | query: string; 109 | state: AgentState; 110 | } 111 | 112 | /** 113 | * Full current turn view including query and progress 114 | */ 115 | export const CurrentTurnViewV2 = React.memo(function CurrentTurnViewV2({ query, state }: CurrentTurnViewV2Props) { 116 | return ( 117 | 118 | {/* User query */} 119 | 120 | {'> '} 121 | {query} 122 | 123 | 124 | {/* Agent progress */} 125 | 126 | 127 | ); 128 | }); 129 | -------------------------------------------------------------------------------- /src/tools/finance/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants for SEC filing items and other tool-related data. 3 | */ 4 | 5 | export const ITEMS_10K_MAP: Record = { 6 | 'Item-1': 'Business', 7 | 'Item-1A': 'Risk Factors', 8 | 'Item-1B': 'Unresolved Staff Comments', 9 | 'Item-2': 'Properties', 10 | 'Item-3': 'Legal Proceedings', 11 | 'Item-4': 'Mine Safety Disclosures', 12 | 'Item-5': 13 | "Market for Registrant's Common Equity, Related Stockholder Matters and Issuer Purchases of Equity Securities", 14 | 'Item-6': '[Reserved]', 15 | 'Item-7': "Management's Discussion and Analysis of Financial Condition and Results of Operations", 16 | 'Item-7A': 'Quantitative and Qualitative Disclosures About Market Risk', 17 | 'Item-8': 'Financial Statements and Supplementary Data', 18 | 'Item-9': 'Changes in and Disagreements With Accountants on Accounting and Financial Disclosure', 19 | 'Item-9A': 'Controls and Procedures', 20 | 'Item-9B': 'Other Information', 21 | 'Item-10': 'Directors, Executive Officers and Corporate Governance', 22 | 'Item-11': 'Executive Compensation', 23 | 'Item-12': 24 | 'Security Ownership of Certain Beneficial Owners and Management and Related Stockholder Matters', 25 | 'Item-13': 'Certain Relationships and Related Transactions, and Director Independence', 26 | 'Item-14': 'Principal Accounting Fees and Services', 27 | 'Item-15': 'Exhibits, Financial Statement Schedules', 28 | 'Item-16': 'Form 10-K Summary', 29 | }; 30 | 31 | export const ITEMS_10Q_MAP: Record = { 32 | 'Item-1': 'Financial Statements', 33 | 'Item-2': "Management's Discussion and Analysis of Financial Condition and Results of Operations", 34 | 'Item-3': 'Quantitative and Qualitative Disclosures About Market Risk', 35 | 'Item-4': 'Controls and Procedures', 36 | }; 37 | 38 | export const ITEMS_8K_MAP: Record = { 39 | 'Item-1.01': 'Entry into a Material Definitive Agreement', 40 | 'Item-1.02': 'Termination of a Material Definitive Agreement', 41 | 'Item-1.03': 'Bankruptcy or Receivership', 42 | 'Item-1.04': 'Mine Safety - Reporting of Shutdowns and Patterns of Violations', 43 | 'Item-2.01': 'Completion of Acquisition or Disposition of Assets', 44 | 'Item-2.02': 'Results of Operations and Financial Condition', 45 | 'Item-2.03': 46 | 'Creation of a Direct Financial Obligation or an Obligation under an Off-Balance Sheet Arrangement', 47 | 'Item-2.04': 'Triggering Events That Accelerate or Increase a Direct Financial Obligation', 48 | 'Item-2.05': 'Costs Associated with Exit or Disposal Activities', 49 | 'Item-2.06': 'Material Impairments', 50 | 'Item-3.01': 'Notice of Delisting or Failure to Satisfy a Continued Listing Rule or Standard', 51 | 'Item-3.02': 'Unregistered Sales of Equity Securities', 52 | 'Item-3.03': 'Material Modification to Rights of Security Holders', 53 | 'Item-4.01': "Changes in Registrant's Certifying Accountant", 54 | 'Item-4.02': 'Non-Reliance on Previously Issued Financial Statements or a Related Audit Report', 55 | 'Item-5.01': 'Changes in Control of Registrant', 56 | 'Item-5.02': 57 | 'Departure of Directors or Certain Officers; Election of Directors; Appointment of Certain Officers', 58 | 'Item-5.03': 'Amendments to Articles of Incorporation or Bylaws; Change in Fiscal Year', 59 | 'Item-5.04': "Temporary Suspension of Trading Under Registrant's Employee Benefit Plans", 60 | 'Item-5.05': 61 | "Amendment to Registrant's Code of Ethics, or Waiver of a Provision of the Code of Ethics", 62 | 'Item-5.06': 'Change in Shell Company Status', 63 | 'Item-5.07': 'Submission of Matters to a Vote of Security Holders', 64 | 'Item-5.08': 'Shareholder Director Nominations', 65 | 'Item-6.01': 'ABS Informational and Computational Material', 66 | 'Item-6.02': 'Change of Servicer or Trustee', 67 | 'Item-6.03': 'Change in Credit Enhancement or Other External Support', 68 | 'Item-6.04': 'Failure to Make a Required Distribution', 69 | 'Item-6.05': 'Securities Act Updating Disclosure', 70 | 'Item-7.01': 'Regulation FD Disclosure', 71 | 'Item-8.01': 'Other Events', 72 | 'Item-9.01': 'Financial Statements and Exhibits', 73 | }; 74 | 75 | export const ITEMS_10K = Object.keys(ITEMS_10K_MAP); 76 | export const ITEMS_10Q = Object.keys(ITEMS_10Q_MAP); 77 | export const ITEMS_8K = Object.keys(ITEMS_8K_MAP); 78 | 79 | export function formatItemsDescription(itemsMap: Record): string { 80 | return Object.entries(itemsMap) 81 | .map(([item, description]) => ` - ${item}: ${description}`) 82 | .join('\n'); 83 | } 84 | 85 | -------------------------------------------------------------------------------- /src/tools/finance/fundamentals.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { z } from 'zod'; 3 | import { callApi } from './api.js'; 4 | import { formatToolResult } from '../types.js'; 5 | 6 | const FinancialStatementsInputSchema = z.object({ 7 | ticker: z 8 | .string() 9 | .describe( 10 | "The stock ticker symbol to fetch financial statements for. For example, 'AAPL' for Apple." 11 | ), 12 | period: z 13 | .enum(['annual', 'quarterly', 'ttm']) 14 | .describe( 15 | "The reporting period for the financial statements. 'annual' for yearly, 'quarterly' for quarterly, and 'ttm' for trailing twelve months." 16 | ), 17 | limit: z 18 | .number() 19 | .default(10) 20 | .describe( 21 | 'Maximum number of report periods to return (default: 10). Returns the most recent N periods based on the period type.' 22 | ), 23 | report_period_gt: z 24 | .string() 25 | .optional() 26 | .describe('Filter for financial statements with report periods after this date (YYYY-MM-DD).'), 27 | report_period_gte: z 28 | .string() 29 | .optional() 30 | .describe( 31 | 'Filter for financial statements with report periods on or after this date (YYYY-MM-DD).' 32 | ), 33 | report_period_lt: z 34 | .string() 35 | .optional() 36 | .describe('Filter for financial statements with report periods before this date (YYYY-MM-DD).'), 37 | report_period_lte: z 38 | .string() 39 | .optional() 40 | .describe( 41 | 'Filter for financial statements with report periods on or before this date (YYYY-MM-DD).' 42 | ), 43 | }); 44 | 45 | function createParams(input: z.infer): Record { 46 | return { 47 | ticker: input.ticker, 48 | period: input.period, 49 | limit: input.limit, 50 | report_period_gt: input.report_period_gt, 51 | report_period_gte: input.report_period_gte, 52 | report_period_lt: input.report_period_lt, 53 | report_period_lte: input.report_period_lte, 54 | }; 55 | } 56 | 57 | export const getIncomeStatements = new DynamicStructuredTool({ 58 | name: 'get_income_statements', 59 | description: `Fetches a company's income statements, detailing its revenues, expenses, net income, etc. over a reporting period. Useful for evaluating a company's profitability and operational efficiency.`, 60 | schema: FinancialStatementsInputSchema, 61 | func: async (input) => { 62 | const params = createParams(input); 63 | const { data, url } = await callApi('/financials/income-statements/', params); 64 | return formatToolResult(data.income_statements || {}, [url]); 65 | }, 66 | }); 67 | 68 | export const getBalanceSheets = new DynamicStructuredTool({ 69 | name: 'get_balance_sheets', 70 | description: `Retrieves a company's balance sheets, providing a snapshot of its assets, liabilities, shareholders' equity, etc. at a specific point in time. Useful for assessing a company's financial position.`, 71 | schema: FinancialStatementsInputSchema, 72 | func: async (input) => { 73 | const params = createParams(input); 74 | const { data, url } = await callApi('/financials/balance-sheets/', params); 75 | return formatToolResult(data.balance_sheets || {}, [url]); 76 | }, 77 | }); 78 | 79 | export const getCashFlowStatements = new DynamicStructuredTool({ 80 | name: 'get_cash_flow_statements', 81 | description: `Retrieves a company's cash flow statements, showing how cash is generated and used across operating, investing, and financing activities. Useful for understanding a company's liquidity and solvency.`, 82 | schema: FinancialStatementsInputSchema, 83 | func: async (input) => { 84 | const params = createParams(input); 85 | const { data, url } = await callApi('/financials/cash-flow-statements/', params); 86 | return formatToolResult(data.cash_flow_statements || {}, [url]); 87 | }, 88 | }); 89 | 90 | export const getAllFinancialStatements = new DynamicStructuredTool({ 91 | name: 'get_all_financial_statements', 92 | description: `Retrieves all three financial statements (income statements, balance sheets, and cash flow statements) for a company in a single API call. This is more efficient than calling each statement type separately when you need all three for comprehensive financial analysis.`, 93 | schema: FinancialStatementsInputSchema, 94 | func: async (input) => { 95 | const params = createParams(input); 96 | const { data, url } = await callApi('/financials/', params); 97 | return formatToolResult(data.financials || {}, [url]); 98 | }, 99 | }); 100 | 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dexter 🤖 2 | 3 | Dexter is an autonomous financial research agent that thinks, plans, and learns as it works. It performs analysis using task planning, self-reflection, and real-time market data. Think Claude Code, but built specifically for financial research. 4 | 5 | 6 | Screenshot 2025-10-14 at 6 12 35 PM 7 | 8 | ## Overview 9 | 10 | Dexter takes complex financial questions and turns them into clear, step-by-step research plans. It runs those tasks using live market data, checks its own work, and refines the results until it has a confident, data-backed answer. 11 | 12 | **Key Capabilities:** 13 | - **Intelligent Task Planning**: Automatically decomposes complex queries into structured research steps 14 | - **Autonomous Execution**: Selects and executes the right tools to gather financial data 15 | - **Self-Validation**: Checks its own work and iterates until tasks are complete 16 | - **Real-Time Financial Data**: Access to income statements, balance sheets, and cash flow statements 17 | - **Safety Features**: Built-in loop detection and step limits to prevent runaway execution 18 | 19 | [![Twitter Follow](https://img.shields.io/twitter/follow/virattt?style=social)](https://twitter.com/virattt) 20 | 21 | Screenshot 2025-11-22 at 1 45 07 PM 22 | 23 | 24 | ### Prerequisites 25 | 26 | - [Bun](https://bun.com) runtime (v1.0 or higher) 27 | - OpenAI API key (get [here](https://platform.openai.com/api-keys)) 28 | - Financial Datasets API key (get [here](https://financialdatasets.ai)) 29 | - Tavily API key (get [here](https://tavily.com)) - optional, for web search 30 | 31 | #### Installing Bun 32 | 33 | If you don't have Bun installed, you can install it using curl: 34 | 35 | **macOS/Linux:** 36 | ```bash 37 | curl -fsSL https://bun.com/install | bash 38 | ``` 39 | 40 | **Windows:** 41 | ```bash 42 | powershell -c "irm bun.sh/install.ps1|iex" 43 | ``` 44 | 45 | After installation, restart your terminal and verify Bun is installed: 46 | ```bash 47 | bun --version 48 | ``` 49 | 50 | ### Installing Dexter 51 | 52 | 1. Clone the repository: 53 | ```bash 54 | git clone https://github.com/virattt/dexter.git 55 | cd dexter 56 | ``` 57 | 58 | 2. Install dependencies with Bun: 59 | ```bash 60 | bun install 61 | ``` 62 | 63 | 3. Set up your environment variables: 64 | ```bash 65 | # Copy the example environment file (from parent directory) 66 | cp ../env.example .env 67 | 68 | # Edit .env and add your API keys 69 | # OPENAI_API_KEY=your-openai-api-key 70 | # FINANCIAL_DATASETS_API_KEY=your-financial-datasets-api-key 71 | # TAVILY_API_KEY=your-tavily-api-key 72 | ``` 73 | 74 | ### Usage 75 | 76 | Run Dexter in interactive mode: 77 | ```bash 78 | bun start 79 | ``` 80 | 81 | Or with watch mode for development: 82 | ```bash 83 | bun dev 84 | ``` 85 | 86 | ### Example Queries 87 | 88 | Try asking Dexter questions like: 89 | - "What was Apple's revenue growth over the last 4 quarters?" 90 | - "Compare Microsoft and Google's operating margins for 2023" 91 | - "Analyze Tesla's cash flow trends over the past year" 92 | - "What is Amazon's debt-to-equity ratio based on recent financials?" 93 | 94 | Dexter will automatically: 95 | 1. Break down your question into research tasks 96 | 2. Fetch the necessary financial data 97 | 3. Perform calculations and analysis 98 | 4. Provide a comprehensive, data-rich answer 99 | 100 | ## Architecture 101 | 102 | Dexter uses a multi-agent architecture with specialized components: 103 | 104 | - **Planning Agent**: Analyzes queries and creates structured task lists 105 | - **Action Agent**: Selects appropriate tools and executes research steps 106 | - **Validation Agent**: Verifies task completion and data sufficiency 107 | - **Answer Agent**: Synthesizes findings into comprehensive responses 108 | 109 | ## Tech Stack 110 | 111 | - **Runtime**: [Bun](https://bun.sh) 112 | - **UI Framework**: [React](https://react.dev) + [Ink](https://github.com/vadimdemedes/ink) (terminal UI) 113 | - **LLM Integration**: [LangChain.js](https://js.langchain.com) with multi-provider support (OpenAI, Anthropic, Google) 114 | - **Schema Validation**: [Zod](https://zod.dev) 115 | - **Language**: TypeScript 116 | 117 | 118 | ### Changing Models 119 | 120 | Type `/model` in the CLI to switch between: 121 | - GPT 4.1 (OpenAI) 122 | - Claude Sonnet 4.5 (Anthropic) 123 | - Gemini 3 (Google) 124 | 125 | ## How to Contribute 126 | 127 | 1. Fork the repository 128 | 2. Create a feature branch 129 | 3. Commit your changes 130 | 4. Push to the branch 131 | 5. Create a Pull Request 132 | 133 | **Important**: Please keep your pull requests small and focused. This will make it easier to review and merge. 134 | 135 | 136 | ## License 137 | 138 | This project is licensed under the MIT License. 139 | 140 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 2 | import { config } from 'dotenv'; 3 | 4 | // Load .env on module import 5 | config({ quiet: true }); 6 | 7 | // Map model IDs to their required API key environment variable names 8 | const MODEL_API_KEY_MAP: Record = { 9 | 'gpt-5.2': 'OPENAI_API_KEY', 10 | 'claude-sonnet-4-5': 'ANTHROPIC_API_KEY', 11 | 'gemini-3': 'GOOGLE_API_KEY', 12 | }; 13 | 14 | // Map API key names to user-friendly provider names 15 | const API_KEY_PROVIDER_NAMES: Record = { 16 | OPENAI_API_KEY: 'OpenAI', 17 | ANTHROPIC_API_KEY: 'Anthropic', 18 | GOOGLE_API_KEY: 'Google', 19 | }; 20 | 21 | export function getApiKeyName(modelId: string): string | undefined { 22 | return MODEL_API_KEY_MAP[modelId]; 23 | } 24 | 25 | export function checkApiKeyExists(apiKeyName: string): boolean { 26 | const value = process.env[apiKeyName]; 27 | if (value && value.trim() && !value.trim().startsWith('your-')) { 28 | return true; 29 | } 30 | 31 | // Also check .env file directly 32 | if (existsSync('.env')) { 33 | const envContent = readFileSync('.env', 'utf-8'); 34 | const lines = envContent.split('\n'); 35 | for (const line of lines) { 36 | const trimmed = line.trim(); 37 | if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) { 38 | const [key, ...valueParts] = trimmed.split('='); 39 | if (key.trim() === apiKeyName) { 40 | const val = valueParts.join('=').trim(); 41 | if (val && !val.startsWith('your-')) { 42 | return true; 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | return false; 50 | } 51 | 52 | export function saveApiKeyToEnv(apiKeyName: string, apiKeyValue: string): boolean { 53 | try { 54 | let lines: string[] = []; 55 | let keyUpdated = false; 56 | 57 | if (existsSync('.env')) { 58 | const existingContent = readFileSync('.env', 'utf-8'); 59 | const existingLines = existingContent.split('\n'); 60 | 61 | for (const line of existingLines) { 62 | const stripped = line.trim(); 63 | if (!stripped || stripped.startsWith('#')) { 64 | lines.push(line); 65 | } else if (stripped.includes('=')) { 66 | const key = stripped.split('=')[0].trim(); 67 | if (key === apiKeyName) { 68 | lines.push(`${apiKeyName}=${apiKeyValue}`); 69 | keyUpdated = true; 70 | } else { 71 | lines.push(line); 72 | } 73 | } else { 74 | lines.push(line); 75 | } 76 | } 77 | 78 | if (!keyUpdated) { 79 | if (lines.length > 0 && !lines[lines.length - 1].endsWith('\n')) { 80 | lines.push(''); 81 | } 82 | lines.push(`${apiKeyName}=${apiKeyValue}`); 83 | } 84 | } else { 85 | lines.push('# LLM API Keys'); 86 | lines.push(`${apiKeyName}=${apiKeyValue}`); 87 | } 88 | 89 | writeFileSync('.env', lines.join('\n')); 90 | 91 | // Reload environment variables 92 | config({ override: true, quiet: true }); 93 | 94 | return true; 95 | } catch (e) { 96 | console.error(`Error saving API key to .env file: ${e}`); 97 | return false; 98 | } 99 | } 100 | 101 | export async function promptForApiKey(apiKeyName: string): Promise { 102 | const providerName = API_KEY_PROVIDER_NAMES[apiKeyName] || apiKeyName; 103 | 104 | console.log(`\n${providerName} API key is required to continue.`); 105 | console.log(`Please enter your ${apiKeyName}:`); 106 | 107 | // Use readline for input 108 | const readline = await import('readline'); 109 | const rl = readline.createInterface({ 110 | input: process.stdin, 111 | output: process.stdout, 112 | }); 113 | 114 | return new Promise((resolve) => { 115 | rl.question('> ', (answer) => { 116 | rl.close(); 117 | const apiKey = answer.trim(); 118 | if (!apiKey) { 119 | console.log('No API key entered. Cancelled.'); 120 | resolve(null); 121 | } else { 122 | resolve(apiKey); 123 | } 124 | }); 125 | }); 126 | } 127 | 128 | export async function ensureApiKeyForModel(modelId: string): Promise { 129 | const apiKeyName = getApiKeyName(modelId); 130 | if (!apiKeyName) { 131 | console.log(`Warning: Unknown model '${modelId}', cannot verify API key.`); 132 | return false; 133 | } 134 | 135 | // Check if API key already exists 136 | if (checkApiKeyExists(apiKeyName)) { 137 | return true; 138 | } 139 | 140 | // Prompt user for API key 141 | const providerName = API_KEY_PROVIDER_NAMES[apiKeyName] || apiKeyName; 142 | const apiKey = await promptForApiKey(apiKeyName); 143 | 144 | if (!apiKey) { 145 | return false; 146 | } 147 | 148 | // Save to .env file 149 | if (saveApiKeyToEnv(apiKeyName, apiKey)) { 150 | console.log(`\n✓ ${providerName} API key saved to .env file`); 151 | return true; 152 | } else { 153 | console.log(`\n✗ Failed to save ${providerName} API key`); 154 | return false; 155 | } 156 | } 157 | 158 | -------------------------------------------------------------------------------- /src/model/llm.ts: -------------------------------------------------------------------------------- 1 | import { ChatOpenAI } from '@langchain/openai'; 2 | import { ChatAnthropic } from '@langchain/anthropic'; 3 | import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; 4 | import { ChatPromptTemplate } from '@langchain/core/prompts'; 5 | import { BaseChatModel } from '@langchain/core/language_models/chat_models'; 6 | import { StructuredToolInterface } from '@langchain/core/tools'; 7 | import { Runnable } from '@langchain/core/runnables'; 8 | import { z } from 'zod'; 9 | import { DEFAULT_SYSTEM_PROMPT } from '../agent/prompts.js'; 10 | 11 | export const DEFAULT_MODEL = 'gpt-5.2'; 12 | 13 | // Generic retry helper with exponential backoff 14 | async function withRetry(fn: () => Promise, maxAttempts = 3): Promise { 15 | for (let attempt = 0; attempt < maxAttempts; attempt++) { 16 | try { 17 | return await fn(); 18 | } catch (e) { 19 | if (attempt === maxAttempts - 1) throw e; 20 | await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); 21 | } 22 | } 23 | throw new Error('Unreachable'); 24 | } 25 | 26 | // Model provider configuration 27 | interface ModelOpts { 28 | temperature: number; 29 | streaming: boolean; 30 | } 31 | 32 | type ModelFactory = (name: string, opts: ModelOpts) => BaseChatModel; 33 | 34 | function getApiKey(envVar: string, providerName: string): string { 35 | const apiKey = process.env[envVar]; 36 | if (!apiKey) { 37 | throw new Error(`${envVar} not found in environment variables`); 38 | } 39 | return apiKey; 40 | } 41 | 42 | const MODEL_PROVIDERS: Record = { 43 | 'claude-': (name, opts) => 44 | new ChatAnthropic({ 45 | model: name, 46 | ...opts, 47 | apiKey: getApiKey('ANTHROPIC_API_KEY', 'Anthropic'), 48 | }), 49 | 'gemini-': (name, opts) => 50 | new ChatGoogleGenerativeAI({ 51 | model: name, 52 | ...opts, 53 | apiKey: getApiKey('GOOGLE_API_KEY', 'Google'), 54 | }), 55 | }; 56 | 57 | const DEFAULT_PROVIDER: ModelFactory = (name, opts) => 58 | new ChatOpenAI({ 59 | model: name, 60 | ...opts, 61 | apiKey: process.env.OPENAI_API_KEY, 62 | }); 63 | 64 | export function getChatModel( 65 | modelName: string = DEFAULT_MODEL, 66 | temperature: number = 0, 67 | streaming: boolean = false 68 | ): BaseChatModel { 69 | const opts: ModelOpts = { temperature, streaming }; 70 | const prefix = Object.keys(MODEL_PROVIDERS).find((p) => modelName.startsWith(p)); 71 | const factory = prefix ? MODEL_PROVIDERS[prefix] : DEFAULT_PROVIDER; 72 | return factory(modelName, opts); 73 | } 74 | 75 | interface CallLlmOptions { 76 | model?: string; 77 | systemPrompt?: string; 78 | outputSchema?: z.ZodType; 79 | tools?: StructuredToolInterface[]; 80 | } 81 | 82 | export async function callLlm(prompt: string, options: CallLlmOptions = {}): Promise { 83 | const { model = DEFAULT_MODEL, systemPrompt, outputSchema, tools } = options; 84 | const finalSystemPrompt = systemPrompt || DEFAULT_SYSTEM_PROMPT; 85 | 86 | const promptTemplate = ChatPromptTemplate.fromMessages([ 87 | ['system', finalSystemPrompt], 88 | ['user', '{prompt}'], 89 | ]); 90 | 91 | const llm = getChatModel(model, 0, false); 92 | 93 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 94 | let runnable: Runnable = llm; 95 | 96 | if (outputSchema) { 97 | runnable = llm.withStructuredOutput(outputSchema); 98 | } else if (tools && tools.length > 0 && llm.bindTools) { 99 | runnable = llm.bindTools(tools); 100 | } 101 | 102 | const chain = promptTemplate.pipe(runnable); 103 | 104 | const result = await withRetry(() => chain.invoke({ prompt })); 105 | 106 | // If no outputSchema and no tools, extract content from AIMessage 107 | // When tools are provided, return the full AIMessage to preserve tool_calls 108 | if (!outputSchema && !tools && result && typeof result === 'object' && 'content' in result) { 109 | return (result as { content: string }).content; 110 | } 111 | return result; 112 | } 113 | 114 | export async function* callLlmStream( 115 | prompt: string, 116 | options: { model?: string; systemPrompt?: string } = {} 117 | ): AsyncGenerator { 118 | const { model = DEFAULT_MODEL, systemPrompt } = options; 119 | const finalSystemPrompt = systemPrompt || DEFAULT_SYSTEM_PROMPT; 120 | 121 | const promptTemplate = ChatPromptTemplate.fromMessages([ 122 | ['system', finalSystemPrompt], 123 | ['user', '{prompt}'], 124 | ]); 125 | 126 | const llm = getChatModel(model, 0, true); 127 | const chain = promptTemplate.pipe(llm); 128 | 129 | // For streaming, we handle retry at the connection level 130 | for (let attempt = 0; attempt < 3; attempt++) { 131 | try { 132 | const stream = await chain.stream({ prompt }); 133 | 134 | for await (const chunk of stream) { 135 | if (chunk && typeof chunk === 'object' && 'content' in chunk) { 136 | const content = chunk.content; 137 | if (content && typeof content === 'string') { 138 | yield content; 139 | } 140 | } 141 | } 142 | return; 143 | } catch (e) { 144 | if (attempt === 2) throw e; 145 | await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/tools/finance/filings.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { z } from 'zod'; 3 | import { callApi } from './api.js'; 4 | import { ITEMS_10K_MAP, ITEMS_10Q_MAP, formatItemsDescription } from './constants.js'; 5 | import { formatToolResult } from '../types.js'; 6 | 7 | const FilingsInputSchema = z.object({ 8 | ticker: z 9 | .string() 10 | .describe("The stock ticker symbol to fetch filings for. For example, 'AAPL' for Apple."), 11 | filing_type: z 12 | .enum(['10-K', '10-Q', '8-K']) 13 | .optional() 14 | .describe( 15 | "REQUIRED when searching for a specific filing type. Use '10-K' for annual reports, '10-Q' for quarterly reports, or '8-K' for current reports. If omitted, returns most recent filings of ANY type." 16 | ), 17 | limit: z 18 | .number() 19 | .default(10) 20 | .describe( 21 | 'Maximum number of filings to return (default: 10). Returns the most recent N filings matching the criteria.' 22 | ), 23 | }); 24 | 25 | export const getFilings = new DynamicStructuredTool({ 26 | name: 'get_filings', 27 | description: `Retrieves metadata for SEC filings for a company. Returns accession numbers, filing types, and document URLs. This tool ONLY returns metadata - it does NOT return the actual text content from filings. To retrieve text content, use the specific filing items tools: get_10K_filing_items, get_10Q_filing_items, or get_8K_filing_items.`, 28 | schema: FilingsInputSchema, 29 | func: async (input) => { 30 | const params: Record = { 31 | ticker: input.ticker, 32 | limit: input.limit, 33 | filing_type: input.filing_type, 34 | }; 35 | const { data, url } = await callApi('/filings/', params); 36 | return formatToolResult(data.filings || [], [url]); 37 | }, 38 | }); 39 | 40 | const Filing10KItemsInputSchema = z.object({ 41 | ticker: z.string().describe("The stock ticker symbol. For example, 'AAPL' for Apple."), 42 | year: z.number().describe('The year of the 10-K filing. For example, 2023.'), 43 | item: z 44 | .array(z.string()) 45 | .optional() 46 | .describe( 47 | `Optional list of specific items to retrieve from the 10-K. Valid items are:\n${formatItemsDescription(ITEMS_10K_MAP)}\nIf not specified, all available items will be returned.` 48 | ), 49 | }); 50 | 51 | export const get10KFilingItems = new DynamicStructuredTool({ 52 | name: 'get_10K_filing_items', 53 | description: `Retrieves specific sections (items) from a company's 10-K annual report. Use this to extract detailed information from specific sections of a 10-K filing, such as: Item-1: Business, Item-1A: Risk Factors, Item-7: Management's Discussion and Analysis, Item-8: Financial Statements and Supplementary Data. The optional 'item' parameter allows you to filter for specific sections.`, 54 | schema: Filing10KItemsInputSchema, 55 | func: async (input) => { 56 | const params: Record = { 57 | ticker: input.ticker.toUpperCase(), 58 | filing_type: '10-K', 59 | year: input.year, 60 | item: input.item, 61 | }; 62 | const { data, url } = await callApi('/filings/items/', params); 63 | return formatToolResult(data, [url]); 64 | }, 65 | }); 66 | 67 | const Filing10QItemsInputSchema = z.object({ 68 | ticker: z.string().describe("The stock ticker symbol. For example, 'AAPL' for Apple."), 69 | year: z.number().describe('The year of the 10-Q filing. For example, 2023.'), 70 | quarter: z.number().describe('The quarter of the 10-Q filing (1, 2, 3, or 4).'), 71 | item: z 72 | .array(z.string()) 73 | .optional() 74 | .describe( 75 | `Optional list of specific items to retrieve from the 10-Q. Valid items are:\n${formatItemsDescription(ITEMS_10Q_MAP)}\nIf not specified, all available items will be returned.` 76 | ), 77 | }); 78 | 79 | export const get10QFilingItems = new DynamicStructuredTool({ 80 | name: 'get_10Q_filing_items', 81 | description: `Retrieves specific sections (items) from a company's 10-Q quarterly report. Use this to extract detailed information from specific sections of a 10-Q filing, such as: Item-1: Financial Statements, Item-2: Management's Discussion and Analysis, Item-3: Quantitative and Qualitative Disclosures About Market Risk, Item-4: Controls and Procedures.`, 82 | schema: Filing10QItemsInputSchema, 83 | func: async (input) => { 84 | const params: Record = { 85 | ticker: input.ticker.toUpperCase(), 86 | filing_type: '10-Q', 87 | year: input.year, 88 | quarter: input.quarter, 89 | item: input.item, 90 | }; 91 | const { data, url } = await callApi('/filings/items/', params); 92 | return formatToolResult(data, [url]); 93 | }, 94 | }); 95 | 96 | const Filing8KItemsInputSchema = z.object({ 97 | ticker: z.string().describe("The stock ticker symbol. For example, 'AAPL' for Apple."), 98 | accession_number: z 99 | .string() 100 | .describe( 101 | "The SEC accession number for the 8-K filing. For example, '0000320193-24-000123'. This can be retrieved from the get_filings tool." 102 | ), 103 | }); 104 | 105 | export const get8KFilingItems = new DynamicStructuredTool({ 106 | name: 'get_8K_filing_items', 107 | description: `Retrieves specific sections (items) from a company's 8-K current report. 8-K filings report material events such as acquisitions, financial results, management changes, and other significant corporate events. The accession_number parameter can be retrieved using the get_filings tool by filtering for 8-K filings.`, 108 | schema: Filing8KItemsInputSchema, 109 | func: async (input) => { 110 | const params: Record = { 111 | ticker: input.ticker.toUpperCase(), 112 | filing_type: '8-K', 113 | accession_number: input.accession_number, 114 | }; 115 | const { data, url } = await callApi('/filings/items/', params); 116 | return formatToolResult(data, [url]); 117 | }, 118 | }); 119 | 120 | -------------------------------------------------------------------------------- /src/utils/message-history.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto'; 2 | import { callLlm, DEFAULT_MODEL } from '../model/llm.js'; 3 | import { MESSAGE_SUMMARY_SYSTEM_PROMPT, MESSAGE_SELECTION_SYSTEM_PROMPT } from '../agent/prompts.js'; 4 | import { z } from 'zod'; 5 | 6 | /** 7 | * Represents a single conversation turn (query + answer + summary) 8 | */ 9 | export interface Message { 10 | id: number; 11 | query: string; 12 | answer: string; 13 | summary: string; // LLM-generated summary of the answer 14 | } 15 | 16 | /** 17 | * Schema for LLM to select relevant messages 18 | */ 19 | export const SelectedMessagesSchema = z.object({ 20 | message_ids: z.array(z.number()).describe('List of relevant message IDs (0-indexed)'), 21 | }); 22 | 23 | /** 24 | * Manages in-memory conversation history for multi-turn conversations. 25 | * Stores user queries, final answers, and LLM-generated summaries. 26 | * Follows a similar pattern to ToolContextManager: save with summary, select relevant, load full content. 27 | */ 28 | export class MessageHistory { 29 | private messages: Message[] = []; 30 | private model: string; 31 | private relevantMessagesByQuery: Map = new Map(); 32 | 33 | constructor(model: string = DEFAULT_MODEL) { 34 | this.model = model; 35 | } 36 | 37 | /** 38 | * Hashes a query string for cache key generation 39 | */ 40 | private hashQuery(query: string): string { 41 | return createHash('md5').update(query).digest('hex').slice(0, 12); 42 | } 43 | 44 | /** 45 | * Updates the model used for LLM calls (e.g., when user switches models) 46 | */ 47 | setModel(model: string): void { 48 | this.model = model; 49 | } 50 | 51 | /** 52 | * Generates a brief summary of an answer for later relevance matching 53 | */ 54 | private async generateSummary(query: string, answer: string): Promise { 55 | const answerPreview = answer.slice(0, 1500); // Limit for prompt size 56 | 57 | const prompt = `Query: "${query}" 58 | Answer: "${answerPreview}" 59 | 60 | Generate a brief 1-2 sentence summary of this answer.`; 61 | 62 | try { 63 | const response = await callLlm(prompt, { 64 | systemPrompt: MESSAGE_SUMMARY_SYSTEM_PROMPT, 65 | model: this.model, 66 | }); 67 | return typeof response === 'string' ? response.trim() : String(response).trim(); 68 | } catch { 69 | // Fallback to a simple summary if LLM fails 70 | return `Answer to: ${query.slice(0, 100)}`; 71 | } 72 | } 73 | 74 | /** 75 | * Adds a new conversation turn to history with an LLM-generated summary 76 | */ 77 | async addMessage(query: string, answer: string): Promise { 78 | // Clear the relevance cache since message history has changed 79 | this.relevantMessagesByQuery.clear(); 80 | 81 | const summary = await this.generateSummary(query, answer); 82 | this.messages.push({ 83 | id: this.messages.length, 84 | query, 85 | answer, 86 | summary, 87 | }); 88 | } 89 | 90 | /** 91 | * Uses LLM to select which messages are relevant to the current query. 92 | * Results are cached by query hash to avoid redundant LLM calls within the same query. 93 | */ 94 | async selectRelevantMessages(currentQuery: string): Promise { 95 | if (this.messages.length === 0) { 96 | return []; 97 | } 98 | 99 | // Check cache first 100 | const cacheKey = this.hashQuery(currentQuery); 101 | const cached = this.relevantMessagesByQuery.get(cacheKey); 102 | if (cached) { 103 | return cached; 104 | } 105 | 106 | const messagesInfo = this.messages.map((message) => ({ 107 | id: message.id, 108 | query: message.query, 109 | summary: message.summary, 110 | })); 111 | 112 | const prompt = `Current user query: "${currentQuery}" 113 | 114 | Previous conversations: 115 | ${JSON.stringify(messagesInfo, null, 2)} 116 | 117 | Select which previous messages are relevant to understanding or answering the current query.`; 118 | 119 | try { 120 | const response = await callLlm(prompt, { 121 | systemPrompt: MESSAGE_SELECTION_SYSTEM_PROMPT, 122 | model: this.model, 123 | outputSchema: SelectedMessagesSchema, 124 | }); 125 | 126 | const selectedIds = (response as { message_ids: number[] }).message_ids || []; 127 | 128 | const selectedMessages = selectedIds 129 | .filter((idx) => idx >= 0 && idx < this.messages.length) 130 | .map((idx) => this.messages[idx]); 131 | 132 | // Cache the result 133 | this.relevantMessagesByQuery.set(cacheKey, selectedMessages); 134 | 135 | return selectedMessages; 136 | } catch { 137 | // On failure, return empty (don't inject potentially irrelevant context) 138 | return []; 139 | } 140 | } 141 | 142 | /** 143 | * Formats selected messages for task planning (queries + summaries only, lightweight) 144 | */ 145 | formatForPlanning(messages: Message[]): string { 146 | if (messages.length === 0) { 147 | return ''; 148 | } 149 | 150 | return messages 151 | .map((message) => `User: ${message.query}\nAssistant: ${message.summary}`) 152 | .join('\n\n'); 153 | } 154 | 155 | /** 156 | * Formats selected messages for answer generation (queries + full answers) 157 | */ 158 | formatForAnswerGeneration(messages: Message[]): string { 159 | if (messages.length === 0) { 160 | return ''; 161 | } 162 | 163 | return messages 164 | .map((message) => `User: ${message.query}\nAssistant: ${message.answer}`) 165 | .join('\n\n'); 166 | } 167 | 168 | /** 169 | * Returns all messages 170 | */ 171 | getMessages(): Message[] { 172 | return [...this.messages]; 173 | } 174 | 175 | /** 176 | * Returns true if there are any messages 177 | */ 178 | hasMessages(): boolean { 179 | return this.messages.length > 0; 180 | } 181 | 182 | /** 183 | * Clears all messages and cache 184 | */ 185 | clear(): void { 186 | this.messages = []; 187 | this.relevantMessagesByQuery.clear(); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/utils/context.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { createHash } from 'crypto'; 4 | import { callLlm, DEFAULT_MODEL } from '../model/llm.js'; 5 | import { CONTEXT_SELECTION_SYSTEM_PROMPT } from '../agent/prompts.js'; 6 | import { SelectedContextsSchema } from '../agent/schemas.js'; 7 | import type { ToolSummary } from '../agent/schemas.js'; 8 | 9 | interface ContextPointer { 10 | filepath: string; 11 | filename: string; 12 | toolName: string; 13 | toolDescription: string; 14 | args: Record; 15 | taskId?: number; 16 | queryId?: string; 17 | sourceUrls?: string[]; 18 | } 19 | 20 | interface ContextData { 21 | toolName: string; 22 | toolDescription: string; 23 | args: Record; 24 | timestamp: string; 25 | taskId?: number; 26 | queryId?: string; 27 | sourceUrls?: string[]; 28 | result: unknown; 29 | } 30 | 31 | export class ToolContextManager { 32 | private contextDir: string; 33 | private model: string; 34 | public pointers: ContextPointer[] = []; 35 | 36 | constructor(contextDir: string = '.dexter/context', model: string = DEFAULT_MODEL) { 37 | this.contextDir = contextDir; 38 | this.model = model; 39 | if (!existsSync(contextDir)) { 40 | mkdirSync(contextDir, { recursive: true }); 41 | } 42 | } 43 | 44 | private hashArgs(args: Record): string { 45 | const argsStr = JSON.stringify(args, Object.keys(args).sort()); 46 | return createHash('md5').update(argsStr).digest('hex').slice(0, 12); 47 | } 48 | 49 | hashQuery(query: string): string { 50 | return createHash('md5').update(query).digest('hex').slice(0, 12); 51 | } 52 | 53 | private generateFilename(toolName: string, args: Record): string { 54 | const argsHash = this.hashArgs(args); 55 | const ticker = typeof args.ticker === 'string' ? args.ticker.toUpperCase() : null; 56 | return ticker 57 | ? `${ticker}_${toolName}_${argsHash}.json` 58 | : `${toolName}_${argsHash}.json`; 59 | } 60 | 61 | /** 62 | * Creates a simple description string for the tool using the tool name and arguments. 63 | * The description string is used to identify the tool and is used to select relevant context for the query. 64 | */ 65 | getToolDescription(toolName: string, args: Record): string { 66 | const parts: string[] = []; 67 | const usedKeys = new Set(); 68 | 69 | // Add ticker if present (most common identifier) 70 | if (args.ticker) { 71 | parts.push(String(args.ticker).toUpperCase()); 72 | usedKeys.add('ticker'); 73 | } 74 | 75 | // Add search query if present 76 | if (args.query) { 77 | parts.push(`"${args.query}"`); 78 | usedKeys.add('query'); 79 | } 80 | 81 | // Format tool name: get_income_statements -> income statements 82 | const formattedToolName = toolName 83 | .replace(/^get_/, '') 84 | .replace(/^search_/, '') 85 | .replace(/_/g, ' '); 86 | parts.push(formattedToolName); 87 | 88 | // Add period qualifier if present 89 | if (args.period) { 90 | parts.push(`(${args.period})`); 91 | usedKeys.add('period'); 92 | } 93 | 94 | // Add limit if present 95 | if (args.limit && typeof args.limit === 'number') { 96 | parts.push(`- ${args.limit} periods`); 97 | usedKeys.add('limit'); 98 | } 99 | 100 | // Add date range if present 101 | if (args.start_date && args.end_date) { 102 | parts.push(`from ${args.start_date} to ${args.end_date}`); 103 | usedKeys.add('start_date'); 104 | usedKeys.add('end_date'); 105 | } 106 | 107 | // Append any remaining args not explicitly handled 108 | const remainingArgs = Object.entries(args) 109 | .filter(([key]) => !usedKeys.has(key)) 110 | .map(([key, value]) => `${key}=${value}`); 111 | 112 | if (remainingArgs.length > 0) { 113 | parts.push(`[${remainingArgs.join(', ')}]`); 114 | } 115 | 116 | return parts.join(' '); 117 | } 118 | 119 | saveContext( 120 | toolName: string, 121 | args: Record, 122 | result: unknown, 123 | taskId?: number, 124 | queryId?: string 125 | ): string { 126 | const filename = this.generateFilename(toolName, args); 127 | const filepath = join(this.contextDir, filename); 128 | 129 | const toolDescription = this.getToolDescription(toolName, args); 130 | 131 | // Extract sourceUrls from ToolResult format 132 | let sourceUrls: string[] | undefined; 133 | let actualResult = result; 134 | 135 | if (typeof result === 'string') { 136 | try { 137 | const parsed = JSON.parse(result); 138 | if (parsed.data !== undefined) { 139 | sourceUrls = parsed.sourceUrls; 140 | actualResult = parsed.data; 141 | } 142 | } catch { 143 | // Result is not JSON, use as-is 144 | } 145 | } 146 | 147 | const contextData: ContextData = { 148 | toolName: toolName, 149 | args: args, 150 | toolDescription: toolDescription, 151 | timestamp: new Date().toISOString(), 152 | taskId: taskId, 153 | queryId: queryId, 154 | sourceUrls: sourceUrls, 155 | result: actualResult, 156 | }; 157 | 158 | writeFileSync(filepath, JSON.stringify(contextData, null, 2)); 159 | 160 | const pointer: ContextPointer = { 161 | filepath, 162 | filename, 163 | toolName, 164 | args, 165 | toolDescription, 166 | taskId, 167 | queryId, 168 | sourceUrls, 169 | }; 170 | 171 | this.pointers.push(pointer); 172 | 173 | return filepath; 174 | } 175 | 176 | /** 177 | * Saves context to disk and returns a lightweight ToolSummary for the agent loop. 178 | * Combines saveContext + deterministic summary generation in one call. 179 | */ 180 | saveAndGetSummary( 181 | toolName: string, 182 | args: Record, 183 | result: unknown, 184 | queryId?: string 185 | ): ToolSummary { 186 | const filepath = this.saveContext(toolName, args, result, undefined, queryId); 187 | const summary = this.getToolDescription(toolName, args); 188 | 189 | return { 190 | id: filepath, 191 | toolName, 192 | args, 193 | summary, 194 | }; 195 | } 196 | 197 | getAllPointers(): ContextPointer[] { 198 | return [...this.pointers]; 199 | } 200 | 201 | getPointersForQuery(queryId: string): ContextPointer[] { 202 | return this.pointers.filter(p => p.queryId === queryId); 203 | } 204 | 205 | loadContexts(filepaths: string[]): ContextData[] { 206 | const contexts: ContextData[] = []; 207 | for (const filepath of filepaths) { 208 | try { 209 | const content = readFileSync(filepath, 'utf-8'); 210 | contexts.push(JSON.parse(content)); 211 | } catch (e) { 212 | console.warn(`Warning: Failed to load context file ${filepath}: ${e}`); 213 | } 214 | } 215 | return contexts; 216 | } 217 | 218 | async selectRelevantContexts( 219 | query: string, 220 | availablePointers: ContextPointer[] 221 | ): Promise { 222 | if (availablePointers.length === 0) { 223 | return []; 224 | } 225 | 226 | const pointersInfo = availablePointers.map((ptr, i) => ({ 227 | id: i, 228 | toolName: ptr.toolName, 229 | toolDescription: ptr.toolDescription, 230 | args: ptr.args, 231 | })); 232 | 233 | const prompt = ` 234 | Original user query: "${query}" 235 | 236 | Available tool outputs: 237 | ${JSON.stringify(pointersInfo, null, 2)} 238 | 239 | Select which tool outputs are relevant for answering the query. 240 | Return a JSON object with a "context_ids" field containing a list of IDs (0-indexed) of the relevant outputs. 241 | Only select outputs that contain data directly relevant to answering the query. 242 | `; 243 | 244 | try { 245 | const response = await callLlm(prompt, { 246 | systemPrompt: CONTEXT_SELECTION_SYSTEM_PROMPT, 247 | model: this.model, 248 | outputSchema: SelectedContextsSchema, 249 | }); 250 | 251 | const selectedIds = (response as { context_ids: number[] }).context_ids || []; 252 | 253 | return selectedIds 254 | .filter((idx) => idx >= 0 && idx < availablePointers.length) 255 | .map((idx) => availablePointers[idx].filepath); 256 | } catch (e) { 257 | console.warn(`Warning: Context selection failed: ${e}, loading all contexts`); 258 | return availablePointers.map((ptr) => ptr.filepath); 259 | } 260 | } 261 | } 262 | 263 | -------------------------------------------------------------------------------- /src/hooks/useAgentExecution.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useRef } from 'react'; 2 | import { Agent, AgentCallbacks, ToolCallInfo, ToolCallResult } from '../agent/agent.js'; 3 | import { Iteration, AgentState, ToolCallStep } from '../agent/schemas.js'; 4 | import { MessageHistory } from '../utils/message-history.js'; 5 | import { generateId } from '../cli/types.js'; 6 | 7 | // ============================================================================ 8 | // Types 9 | // ============================================================================ 10 | 11 | /** 12 | * Current turn state for the agent 13 | */ 14 | export interface CurrentTurn { 15 | id: string; 16 | query: string; 17 | state: AgentState; 18 | } 19 | 20 | interface UseAgentExecutionOptions { 21 | model: string; 22 | messageHistory: MessageHistory; 23 | } 24 | 25 | interface UseAgentExecutionResult { 26 | currentTurn: CurrentTurn | null; 27 | answerStream: AsyncGenerator | null; 28 | isProcessing: boolean; 29 | processQuery: (query: string) => Promise; 30 | handleAnswerComplete: (answer: string) => void; 31 | cancelExecution: () => void; 32 | } 33 | 34 | // ============================================================================ 35 | // Hook Implementation 36 | // ============================================================================ 37 | 38 | /** 39 | * Hook that encapsulates agent execution logic including: 40 | * - Iteration tracking 41 | * - Thinking/tool call state management 42 | * - Query processing 43 | * - Answer handling 44 | */ 45 | export function useAgentExecution({ 46 | model, 47 | messageHistory, 48 | }: UseAgentExecutionOptions): UseAgentExecutionResult { 49 | const [currentTurn, setCurrentTurn] = useState(null); 50 | const [answerStream, setAnswerStream] = useState | null>(null); 51 | const [isProcessing, setIsProcessing] = useState(false); 52 | 53 | const currentQueryRef = useRef(null); 54 | const isProcessingRef = useRef(false); 55 | 56 | /** 57 | * Creates a new empty iteration 58 | */ 59 | const createIteration = useCallback((id: number): Iteration => ({ 60 | id, 61 | thinking: null, 62 | toolCalls: [], 63 | status: 'thinking', 64 | }), []); 65 | 66 | /** 67 | * Updates the current iteration's thinking 68 | */ 69 | const setIterationThinking = useCallback((thought: string) => { 70 | setCurrentTurn(prev => { 71 | if (!prev) return prev; 72 | const iterations = [...prev.state.iterations]; 73 | const currentIdx = iterations.length - 1; 74 | if (currentIdx >= 0) { 75 | iterations[currentIdx] = { 76 | ...iterations[currentIdx], 77 | thinking: { thought }, 78 | }; 79 | } 80 | return { 81 | ...prev, 82 | state: { 83 | ...prev.state, 84 | iterations, 85 | }, 86 | }; 87 | }); 88 | }, []); 89 | 90 | /** 91 | * Adds tool calls to the current iteration 92 | */ 93 | const addToolCalls = useCallback((toolCalls: ToolCallInfo[]) => { 94 | setCurrentTurn(prev => { 95 | if (!prev) return prev; 96 | const iterations = [...prev.state.iterations]; 97 | const currentIdx = iterations.length - 1; 98 | if (currentIdx >= 0) { 99 | const newToolCalls: ToolCallStep[] = toolCalls.map(tc => ({ 100 | toolName: tc.name, 101 | args: tc.args, 102 | summary: '', 103 | status: 'running' as const, 104 | })); 105 | iterations[currentIdx] = { 106 | ...iterations[currentIdx], 107 | status: 'acting', 108 | toolCalls: newToolCalls, 109 | }; 110 | } 111 | return { 112 | ...prev, 113 | state: { 114 | ...prev.state, 115 | status: 'executing', 116 | iterations, 117 | }, 118 | }; 119 | }); 120 | }, []); 121 | 122 | /** 123 | * Updates a tool call's status and summary 124 | */ 125 | const updateToolCall = useCallback((result: ToolCallResult) => { 126 | setCurrentTurn(prev => { 127 | if (!prev) return prev; 128 | const iterations = [...prev.state.iterations]; 129 | const currentIdx = iterations.length - 1; 130 | if (currentIdx >= 0) { 131 | const toolCalls = iterations[currentIdx].toolCalls.map(tc => { 132 | if (tc.toolName === result.name && JSON.stringify(tc.args) === JSON.stringify(result.args)) { 133 | return { 134 | ...tc, 135 | summary: result.summary, 136 | status: result.success ? 'completed' as const : 'failed' as const, 137 | }; 138 | } 139 | return tc; 140 | }); 141 | iterations[currentIdx] = { 142 | ...iterations[currentIdx], 143 | toolCalls, 144 | }; 145 | } 146 | return { 147 | ...prev, 148 | state: { 149 | ...prev.state, 150 | iterations, 151 | }, 152 | }; 153 | }); 154 | }, []); 155 | 156 | /** 157 | * Marks the current iteration as complete and prepares for next 158 | */ 159 | const completeIteration = useCallback((iterationNum: number) => { 160 | setCurrentTurn(prev => { 161 | if (!prev) return prev; 162 | const iterations = [...prev.state.iterations]; 163 | const idx = iterationNum - 1; 164 | if (idx >= 0 && idx < iterations.length) { 165 | iterations[idx] = { 166 | ...iterations[idx], 167 | status: 'completed', 168 | }; 169 | } 170 | return { 171 | ...prev, 172 | state: { 173 | ...prev.state, 174 | iterations, 175 | }, 176 | }; 177 | }); 178 | }, []); 179 | 180 | /** 181 | * Creates agent callbacks that update the declarative state 182 | */ 183 | const createAgentCallbacks = useCallback((): AgentCallbacks => ({ 184 | onIterationStart: (iteration) => { 185 | setCurrentTurn(prev => { 186 | if (!prev) return prev; 187 | const newIteration = createIteration(iteration); 188 | return { 189 | ...prev, 190 | state: { 191 | ...prev.state, 192 | status: 'reasoning', 193 | currentIteration: iteration, 194 | iterations: [...prev.state.iterations, newIteration], 195 | }, 196 | }; 197 | }); 198 | }, 199 | onThinking: setIterationThinking, 200 | onToolCallsStart: addToolCalls, 201 | onToolCallComplete: updateToolCall, 202 | onIterationComplete: completeIteration, 203 | onAnswerStart: () => { 204 | setCurrentTurn(prev => { 205 | if (!prev) return prev; 206 | return { 207 | ...prev, 208 | state: { 209 | ...prev.state, 210 | status: 'answering', 211 | }, 212 | }; 213 | }); 214 | }, 215 | onAnswerStream: (stream) => setAnswerStream(stream), 216 | }), [createIteration, setIterationThinking, addToolCalls, updateToolCall, completeIteration]); 217 | 218 | /** 219 | * Handles the completed answer 220 | */ 221 | const handleAnswerComplete = useCallback((answer: string) => { 222 | setCurrentTurn(null); 223 | setAnswerStream(null); 224 | 225 | // Add to message history for multi-turn context 226 | const query = currentQueryRef.current; 227 | if (query && answer) { 228 | messageHistory.addMessage(query, answer).catch(() => { 229 | // Silently ignore errors in adding to history 230 | }); 231 | } 232 | currentQueryRef.current = null; 233 | }, [messageHistory]); 234 | 235 | /** 236 | * Processes a single query through the agent 237 | */ 238 | const processQuery = useCallback( 239 | async (query: string): Promise => { 240 | if (isProcessingRef.current) return; 241 | isProcessingRef.current = true; 242 | setIsProcessing(true); 243 | 244 | // Store current query for message history 245 | currentQueryRef.current = query; 246 | 247 | // Initialize turn state 248 | setCurrentTurn({ 249 | id: generateId(), 250 | query, 251 | state: { 252 | iterations: [], 253 | currentIteration: 0, 254 | status: 'reasoning', 255 | }, 256 | }); 257 | 258 | const callbacks = createAgentCallbacks(); 259 | 260 | try { 261 | const agent = new Agent({ model, callbacks }); 262 | await agent.run(query, messageHistory); 263 | } catch (e) { 264 | setCurrentTurn(null); 265 | currentQueryRef.current = null; 266 | throw e; 267 | } finally { 268 | isProcessingRef.current = false; 269 | setIsProcessing(false); 270 | } 271 | }, 272 | [model, messageHistory, createAgentCallbacks] 273 | ); 274 | 275 | /** 276 | * Cancels the current execution 277 | */ 278 | const cancelExecution = useCallback(() => { 279 | setCurrentTurn(null); 280 | setAnswerStream(null); 281 | isProcessingRef.current = false; 282 | setIsProcessing(false); 283 | }, []); 284 | 285 | return { 286 | currentTurn, 287 | answerStream, 288 | isProcessing, 289 | processQuery, 290 | handleAnswerComplete, 291 | cancelExecution, 292 | }; 293 | } 294 | -------------------------------------------------------------------------------- /src/cli.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * CLI - Agent Interface 4 | * 5 | * Uses the agent with iterative reasoning and progress display. 6 | */ 7 | import React from 'react'; 8 | import { useState, useCallback, useEffect, useRef } from 'react'; 9 | import { Box, Text, Static, useApp, useInput } from 'ink'; 10 | import { config } from 'dotenv'; 11 | 12 | import { Intro } from './components/Intro.js'; 13 | import { Input } from './components/Input.js'; 14 | import { AnswerBox, UserQuery } from './components/AnswerBox.js'; 15 | import { ModelSelector } from './components/ModelSelector.js'; 16 | import { QueueDisplay } from './components/QueueDisplay.js'; 17 | import { StatusMessage } from './components/StatusMessage.js'; 18 | import { CurrentTurnViewV2, AgentProgressView } from './components/AgentProgressView.js'; 19 | import type { Iteration } from './agent/schemas.js'; 20 | 21 | import { useQueryQueue } from './hooks/useQueryQueue.js'; 22 | import { useApiKey } from './hooks/useApiKey.js'; 23 | import { useAgentExecution } from './hooks/useAgentExecution.js'; 24 | 25 | import { getSetting, setSetting } from './utils/config.js'; 26 | import { ensureApiKeyForModel } from './utils/env.js'; 27 | import { MessageHistory } from './utils/message-history.js'; 28 | 29 | import { DEFAULT_MODEL } from './model/llm.js'; 30 | import { colors } from './theme.js'; 31 | 32 | import type { AppState } from './cli/types.js'; 33 | 34 | // Load environment variables 35 | config({ quiet: true }); 36 | 37 | // ============================================================================ 38 | // Completed Turn Type and View 39 | // ============================================================================ 40 | 41 | interface CompletedTurn { 42 | id: string; 43 | query: string; 44 | iterations: Iteration[]; 45 | answer: string; 46 | } 47 | 48 | const CompletedTurnView = React.memo(function CompletedTurnView({ turn }: { turn: CompletedTurn }) { 49 | // Create a "done" state to render the completed iterations 50 | const completedState = { 51 | iterations: turn.iterations, 52 | currentIteration: turn.iterations.length, 53 | status: 'done' as const, 54 | }; 55 | 56 | return ( 57 | 58 | {/* Query */} 59 | 60 | {'> '} 61 | {turn.query} 62 | 63 | 64 | {/* Thinking and tools (chronological) */} 65 | 66 | 67 | {/* Answer */} 68 | 69 | 70 | 71 | 72 | ); 73 | }); 74 | 75 | // ============================================================================ 76 | // Main CLI Component 77 | // ============================================================================ 78 | 79 | export function CLI() { 80 | const { exit } = useApp(); 81 | 82 | const [state, setState] = useState('idle'); 83 | const [model, setModel] = useState(() => getSetting('model', DEFAULT_MODEL)); 84 | const [history, setHistory] = useState([]); 85 | const [statusMessage, setStatusMessage] = useState(null); 86 | 87 | // Store the current turn's iterations when answer starts streaming 88 | const currentIterationsRef = useRef([]); 89 | 90 | const messageHistoryRef = useRef(new MessageHistory(model)); 91 | 92 | const { apiKeyReady } = useApiKey(model); 93 | const { queue: queryQueue, enqueue, shift: shiftQueue, clear: clearQueue } = useQueryQueue(); 94 | 95 | const { 96 | currentTurn, 97 | answerStream, 98 | isProcessing, 99 | processQuery, 100 | handleAnswerComplete: baseHandleAnswerComplete, 101 | cancelExecution, 102 | } = useAgentExecution({ 103 | model, 104 | messageHistory: messageHistoryRef.current, 105 | }); 106 | 107 | // Capture iterations when answer stream starts 108 | useEffect(() => { 109 | if (answerStream && currentTurn) { 110 | currentIterationsRef.current = [...currentTurn.state.iterations]; 111 | } 112 | }, [answerStream, currentTurn]); 113 | 114 | /** 115 | * Handles the completed answer and moves current turn to history 116 | */ 117 | const handleAnswerComplete = useCallback((answer: string) => { 118 | if (currentTurn) { 119 | setHistory(h => [...h, { 120 | id: currentTurn.id, 121 | query: currentTurn.query, 122 | iterations: currentIterationsRef.current, 123 | answer, 124 | }]); 125 | } 126 | baseHandleAnswerComplete(answer); 127 | currentIterationsRef.current = []; 128 | }, [currentTurn, baseHandleAnswerComplete]); 129 | 130 | /** 131 | * Wraps processQuery to handle state transitions and errors 132 | */ 133 | const executeQuery = useCallback( 134 | async (query: string) => { 135 | setState('running'); 136 | try { 137 | await processQuery(query); 138 | } catch (e) { 139 | if ((e as Error).message?.includes('interrupted')) { 140 | setStatusMessage('Operation cancelled.'); 141 | } else { 142 | setStatusMessage(`Error: ${e}`); 143 | } 144 | } finally { 145 | setState('idle'); 146 | } 147 | }, 148 | [processQuery] 149 | ); 150 | 151 | /** 152 | * Process next queued query when state becomes idle 153 | */ 154 | useEffect(() => { 155 | if (state === 'idle' && queryQueue.length > 0) { 156 | const nextQuery = queryQueue[0]; 157 | shiftQueue(); 158 | executeQuery(nextQuery); 159 | } 160 | }, [state, queryQueue, shiftQueue, executeQuery]); 161 | 162 | const handleSubmit = useCallback( 163 | (query: string) => { 164 | // Handle special commands even while running 165 | if (query.toLowerCase() === 'exit' || query.toLowerCase() === 'quit') { 166 | console.log('Goodbye!'); 167 | exit(); 168 | return; 169 | } 170 | 171 | if (query === '/model') { 172 | setState('model_select'); 173 | return; 174 | } 175 | 176 | // Queue the query if already running 177 | if (state === 'running') { 178 | enqueue(query); 179 | return; 180 | } 181 | 182 | // Process immediately if idle 183 | executeQuery(query); 184 | }, 185 | [state, exit, enqueue, executeQuery] 186 | ); 187 | 188 | const handleModelSelect = useCallback( 189 | async (modelId: string | null) => { 190 | if (modelId && modelId !== model) { 191 | const ready = await ensureApiKeyForModel(modelId); 192 | if (ready) { 193 | setModel(modelId); 194 | setSetting('model', modelId); 195 | messageHistoryRef.current.setModel(modelId); 196 | setStatusMessage(`Model changed to ${modelId}`); 197 | } else { 198 | setStatusMessage(`Cannot use model ${modelId} without API key.`); 199 | } 200 | } 201 | setState('idle'); 202 | }, 203 | [model] 204 | ); 205 | 206 | useInput((input, key) => { 207 | if (key.ctrl && input === 'c') { 208 | if (state === 'running') { 209 | setState('idle'); 210 | cancelExecution(); 211 | clearQueue(); 212 | setStatusMessage('Operation cancelled. You can ask a new question or press Ctrl+C again to quit.'); 213 | } else { 214 | console.log('\nGoodbye!'); 215 | exit(); 216 | } 217 | } 218 | }); 219 | 220 | if (state === 'model_select') { 221 | return ( 222 | 223 | 224 | 225 | ); 226 | } 227 | 228 | // Combine intro and history into a single static stream 229 | const staticItems: Array<{ type: 'intro' } | { type: 'turn'; turn: CompletedTurn }> = [ 230 | { type: 'intro' }, 231 | ...history.map(h => ({ type: 'turn' as const, turn: h })), 232 | ]; 233 | 234 | return ( 235 | 236 | {/* Intro + completed history - each item rendered once, never re-rendered */} 237 | 238 | {(item) => 239 | item.type === 'intro' ? ( 240 | 241 | ) : ( 242 | 243 | ) 244 | } 245 | 246 | 247 | {/* Render current in-progress conversation */} 248 | {currentTurn && ( 249 | 250 | {/* Query + thinking + tools */} 251 | 255 | 256 | {/* Streaming answer (appears below progress, chronologically) */} 257 | {answerStream && ( 258 | 259 | 263 | 264 | )} 265 | 266 | )} 267 | 268 | {/* Queued queries */} 269 | 270 | 271 | {/* Status message */} 272 | 273 | 274 | {/* Input bar - always visible and interactive */} 275 | 276 | 277 | 278 | 279 | ); 280 | } 281 | -------------------------------------------------------------------------------- /src/agent/prompts.ts: -------------------------------------------------------------------------------- 1 | // ============================================================================ 2 | // Default System Prompt (fallback for LLM calls) 3 | // ============================================================================ 4 | 5 | export const DEFAULT_SYSTEM_PROMPT = `You are Dexter, an autonomous financial research agent. 6 | Your primary objective is to conduct deep and thorough research on stocks and companies to answer user queries. 7 | You are equipped with a set of powerful tools to gather and analyze financial data. 8 | You should be methodical, breaking down complex questions into manageable steps and using your tools strategically to find the answers. 9 | Always aim to provide accurate, comprehensive, and well-structured information to the user.`; 10 | 11 | // ============================================================================ 12 | // Answer Generation Prompt 13 | // ============================================================================ 14 | 15 | export const ANSWER_SYSTEM_PROMPT = `You are the answer generation component for Dexter, a financial research agent. 16 | Your critical role is to synthesize the collected data into a clear, actionable answer to the user's query. 17 | 18 | Current date: {current_date} 19 | 20 | If data was collected, your answer MUST: 21 | 1. DIRECTLY answer the specific question asked - don't add tangential information 22 | 2. Lead with the KEY FINDING or answer in the first sentence 23 | 3. Include SPECIFIC NUMBERS with proper context (dates, units, comparison points) 24 | 4. Use clear STRUCTURE - separate numbers onto their own lines or simple lists for readability 25 | 5. Provide brief ANALYSIS or insight when relevant (trends, comparisons, implications) 26 | 27 | Format Guidelines: 28 | - Use plain text ONLY - NO markdown (no **, *, _, #, etc.) 29 | - Use line breaks and indentation for structure 30 | - Present key numbers on separate lines for easy scanning 31 | - Use simple bullets (- or *) for lists if needed 32 | - Keep sentences clear and direct 33 | 34 | Multi-turn Conversation Context: 35 | - If previous conversation context is provided, use it to provide coherent follow-up answers 36 | - Reference previous answers naturally when relevant (e.g., "Similar to Apple's Q4 results...") 37 | - Don't repeat information already covered unless it's useful for comparison 38 | 39 | What NOT to do: 40 | - Don't describe the process of gathering data 41 | - Don't include information not requested by the user 42 | - Don't use vague language when specific numbers are available 43 | - Don't repeat data without adding context or insight 44 | 45 | If NO data was collected (query outside scope): 46 | - Answer using general knowledge, being helpful and concise 47 | - Do NOT include a Sources section if no data sources were used 48 | 49 | SOURCES SECTION (REQUIRED when data was collected): 50 | At the END of your answer, include a "Sources:" section listing ONLY the data sources you actually used in your answer. 51 | Format each source as: "number. (brief description): URL" 52 | 53 | Example Sources section: 54 | Sources: 55 | 1. (AAPL income statements): https://api.financialdatasets.ai/financials/income-statements/?ticker=AAPL... 56 | 2. (AAPL price data): https://api.financialdatasets.ai/prices/?ticker=AAPL... 57 | 58 | Rules for Sources: 59 | - Only include sources whose data you actually referenced in your answer 60 | - Do NOT include sources that were available but not used 61 | - Use a short, descriptive label (company ticker + data type) 62 | - If no external data sources were used, omit the Sources section entirely 63 | 64 | Remember: The user wants the ANSWER and the DATA, not a description of your research process.`; 65 | 66 | // ============================================================================ 67 | // Agent Reasoning Loop Prompt (v2) 68 | // ============================================================================ 69 | 70 | /** 71 | * System prompt for the iterative reasoning loop. 72 | * The agent reasons about what data it needs, calls tools, observes results, 73 | * and repeats until it has enough information to answer. 74 | */ 75 | export const AGENT_SYSTEM_PROMPT = `You are Dexter, an autonomous financial research agent. 76 | 77 | Current date: {current_date} 78 | 79 | ## Your Process 80 | 81 | 1. **Think**: Analyze the query and your available data. Explain your reasoning. 82 | 2. **Act**: Call tools to gather the data you need. 83 | 3. **Observe**: Review the data summaries you've collected. 84 | 4. **Repeat** until you have enough data, then call the "finish" tool. 85 | 86 | ## Conversation Context 87 | 88 | You may receive context from previous conversations. Use this to: 89 | - Understand pronouns and references (e.g., "their revenue" refers to a previously discussed company) 90 | - Build on prior analysis without re-fetching the same data 91 | - Provide continuity in multi-turn conversations 92 | 93 | ## Available Data Format 94 | 95 | You will see summaries of data you've already gathered: 96 | - "AAPL income statements (quarterly) - 4 periods" 97 | - "MSFT financial metrics" 98 | - etc. 99 | 100 | These summaries tell you what data is available. You don't need to re-fetch data you already have. 101 | 102 | ## When to Call Tools 103 | 104 | - You need specific financial data (statements, prices, filings, metrics) 105 | - You need to compare multiple companies (fetch data for each) 106 | - You need recent news or analyst estimates 107 | - The user asks about something you don't have data for yet 108 | 109 | ## When to Finish 110 | 111 | Call the "finish" tool when: 112 | - You have all the data needed to comprehensively answer the query 113 | - You've gathered data for all companies/metrics mentioned in the query 114 | - Further tool calls would be redundant 115 | 116 | ## Important Guidelines 117 | 118 | 1. **Be efficient**: Don't call the same tool twice with the same arguments 119 | 2. **Be thorough**: For comparisons, get data for ALL companies mentioned 120 | 3. **Think first**: Always explain your reasoning before calling tools 121 | 4. **Batch calls**: Request multiple tools in one turn when possible 122 | 5. **Know when to stop**: Don't over-fetch - stop when you have enough 123 | 124 | ## Response Format 125 | 126 | Express your thinking in first person, using complete sentences. Write as if you're explaining your reasoning to a colleague - conversational but professional. 127 | 128 | In each turn: 129 | 1. Share your thinking about what data you have and what you still need 130 | 2. Either call tools to get more data, OR call "finish" if ready 131 | 132 | Good examples: 133 | - "Let me get Apple's quarterly income statements to analyze their profit margins." 134 | - "I have the financial data for both companies now. I can compare their profitability." 135 | - "I'll need to fetch Microsoft's metrics as well to make a fair comparison." 136 | 137 | Bad examples (too terse): 138 | - "Need AAPL income statements for margins" 139 | - "Get MSFT data next" 140 | - "Have enough, finishing"`; 141 | 142 | // ============================================================================ 143 | // Context Selection Prompts (used by utils) 144 | // ============================================================================ 145 | 146 | export const CONTEXT_SELECTION_SYSTEM_PROMPT = `You are a context selection agent for Dexter, a financial research agent. 147 | Your job is to identify which tool outputs are relevant for answering a user's query. 148 | 149 | You will be given: 150 | 1. The original user query 151 | 2. A list of available tool outputs with summaries 152 | 153 | Your task: 154 | - Analyze which tool outputs contain data directly relevant to answering the query 155 | - Select only the outputs that are necessary - avoid selecting irrelevant data 156 | - Consider the query's specific requirements (ticker symbols, time periods, metrics, etc.) 157 | - Return a JSON object with a "context_ids" field containing a list of IDs (0-indexed) of relevant outputs 158 | 159 | Example: 160 | If the query asks about "Apple's revenue", select outputs from tools that retrieved Apple's financial data. 161 | If the query asks about "Microsoft's stock price", select outputs from price-related tools for Microsoft. 162 | 163 | Return format: 164 | {{"context_ids": [0, 2, 5]}}`; 165 | 166 | // ============================================================================ 167 | // Message History Prompts (used by utils) 168 | // ============================================================================ 169 | 170 | export const MESSAGE_SUMMARY_SYSTEM_PROMPT = `You are a summarization component for Dexter, a financial research agent. 171 | Your job is to create a brief, informative summary of an answer that was given to a user query. 172 | 173 | The summary should: 174 | - Be 1-2 sentences maximum 175 | - Capture the key information and data points from the answer 176 | - Include specific entities mentioned (company names, ticker symbols, metrics) 177 | - Be useful for determining if this answer is relevant to future queries 178 | 179 | Example input: 180 | {{ 181 | "query": "What are Apple's latest financials?", 182 | "answer": "Apple reported Q4 2024 revenue of $94.9B, up 6% YoY..." 183 | }} 184 | 185 | Example output: 186 | "Financial overview for Apple (AAPL) covering Q4 2024 revenue, earnings, and key metrics."`; 187 | 188 | export const MESSAGE_SELECTION_SYSTEM_PROMPT = `You are a context selection component for Dexter, a financial research agent. 189 | Your job is to identify which previous conversation turns are relevant to the current query. 190 | 191 | You will be given: 192 | 1. The current user query 193 | 2. A list of previous conversation summaries 194 | 195 | Your task: 196 | - Analyze which previous conversations contain context relevant to understanding or answering the current query 197 | - Consider if the current query references previous topics (e.g., "And MSFT's?" after discussing AAPL) 198 | - Select only messages that would help provide context for the current query 199 | - Return a JSON object with an "message_ids" field containing a list of IDs (0-indexed) of relevant messages 200 | 201 | If the current query is self-contained and doesn't reference previous context, return an empty list. 202 | 203 | Return format: 204 | {{"message_ids": [0, 2]}}`; 205 | 206 | // ============================================================================ 207 | // Helper Functions 208 | // ============================================================================ 209 | 210 | /** 211 | * Returns the current date formatted for prompts. 212 | */ 213 | export function getCurrentDate(): string { 214 | const options: Intl.DateTimeFormatOptions = { 215 | weekday: 'long', 216 | year: 'numeric', 217 | month: 'long', 218 | day: 'numeric', 219 | }; 220 | return new Date().toLocaleDateString('en-US', options); 221 | } 222 | 223 | /** 224 | * Returns the answer system prompt with current date injected. 225 | */ 226 | export function getAnswerSystemPrompt(): string { 227 | return ANSWER_SYSTEM_PROMPT.replace('{current_date}', getCurrentDate()); 228 | } 229 | 230 | /** 231 | * Returns the agent system prompt with current date injected. 232 | */ 233 | export function getSystemPrompt(toolSchemas: string): string { 234 | return AGENT_SYSTEM_PROMPT 235 | .replace('{current_date}', getCurrentDate()) 236 | .replace('{tools}', toolSchemas); 237 | } 238 | 239 | /** 240 | * Formats tool summaries for inclusion in the prompt context. 241 | */ 242 | export function formatToolSummaries(summaries: { summary: string }[]): string { 243 | if (summaries.length === 0) { 244 | return 'No data gathered yet.'; 245 | } 246 | 247 | return `Data gathered so far: 248 | ${summaries.map((s, i) => `${i + 1}. ${s.summary}`).join('\n')}`; 249 | } 250 | 251 | /** 252 | * Builds the user prompt for an iteration. 253 | */ 254 | export function buildUserPrompt( 255 | query: string, 256 | summaries: { summary: string }[], 257 | iterationNumber: number, 258 | conversationContext?: string 259 | ): string { 260 | const summariesText = formatToolSummaries(summaries); 261 | 262 | const contextSection = conversationContext 263 | ? `Previous conversation (for context): 264 | ${conversationContext} 265 | 266 | --- 267 | 268 | ` 269 | : ''; 270 | 271 | return `${contextSection}User query: "${query}" 272 | 273 | ${summariesText} 274 | 275 | ${iterationNumber === 1 276 | ? 'This is your first turn. What data do you need to answer this query?' 277 | : 'Review what you have. Do you need more data, or are you ready to answer?'}`; 278 | } 279 | -------------------------------------------------------------------------------- /src/agent/agent.ts: -------------------------------------------------------------------------------- 1 | import { DynamicStructuredTool } from '@langchain/core/tools'; 2 | import { StructuredToolInterface } from '@langchain/core/tools'; 3 | import { AIMessage } from '@langchain/core/messages'; 4 | import { z } from 'zod'; 5 | import { ToolSummary } from './schemas.js'; 6 | import { ToolContextManager } from '../utils/context.js'; 7 | import { MessageHistory } from '../utils/message-history.js'; 8 | import { callLlm, callLlmStream } from '../model/llm.js'; 9 | import { TOOLS } from '../tools/index.js'; 10 | import { getSystemPrompt, buildUserPrompt, getAnswerSystemPrompt } from './prompts.js'; 11 | 12 | // ============================================================================ 13 | // Types 14 | // ============================================================================ 15 | 16 | /** 17 | * Tool call information for callbacks 18 | */ 19 | export interface ToolCallInfo { 20 | name: string; 21 | args: Record; 22 | } 23 | 24 | /** 25 | * Tool call result for callbacks 26 | */ 27 | export interface ToolCallResult { 28 | name: string; 29 | args: Record; 30 | summary: string; 31 | success: boolean; 32 | } 33 | 34 | /** 35 | * Callbacks for observing agent execution 36 | */ 37 | export interface AgentCallbacks { 38 | /** Called when a new iteration starts */ 39 | onIterationStart?: (iteration: number) => void; 40 | /** Called when the agent expresses its thinking */ 41 | onThinking?: (thought: string) => void; 42 | /** Called when tool calls are about to be executed */ 43 | onToolCallsStart?: (toolCalls: ToolCallInfo[]) => void; 44 | /** Called when a single tool call completes */ 45 | onToolCallComplete?: (result: ToolCallResult) => void; 46 | /** Called when an iteration completes */ 47 | onIterationComplete?: (iteration: number) => void; 48 | /** Called when the answer generation phase starts */ 49 | onAnswerStart?: () => void; 50 | /** Called with the answer stream */ 51 | onAnswerStream?: (stream: AsyncGenerator) => void; 52 | } 53 | 54 | /** 55 | * Options for creating an Agent 56 | */ 57 | export interface AgentOptions { 58 | /** LLM model to use */ 59 | model: string; 60 | /** Callbacks to observe agent execution */ 61 | callbacks?: AgentCallbacks; 62 | /** Maximum number of iterations (default: 5) */ 63 | maxIterations?: number; 64 | } 65 | 66 | // ============================================================================ 67 | // Finish Tool 68 | // ============================================================================ 69 | 70 | const FinishToolSchema = z.object({ 71 | reason: z.string().describe('Brief explanation of why you have enough data to answer the query'), 72 | }); 73 | 74 | /** 75 | * Creates the "finish" tool that signals the agent is ready to generate an answer. 76 | */ 77 | function createFinishTool(onFinish: (reason: string) => void): StructuredToolInterface { 78 | return new DynamicStructuredTool({ 79 | name: 'finish', 80 | description: 'Call this tool when you have gathered enough data to comprehensively answer the user\'s query. Do not call this until you have all the data you need.', 81 | schema: FinishToolSchema, 82 | func: async ({ reason }) => { 83 | onFinish(reason); 84 | return 'Ready to generate answer.'; 85 | }, 86 | }); 87 | } 88 | 89 | // ============================================================================ 90 | // Agent Implementation 91 | // ============================================================================ 92 | 93 | /** 94 | * Agent that iteratively reasons and acts until it has enough data. 95 | * 96 | * Architecture: 97 | * 1. Agent Loop: Reason about query → Call tools → Observe summaries → Repeat 98 | * 2. Context Management: Tool outputs saved to disk, only summaries kept in context 99 | * 3. Answer Generation: Load relevant full data from disk, generate comprehensive answer 100 | */ 101 | export class Agent { 102 | private readonly callbacks: AgentCallbacks; 103 | private readonly model: string; 104 | private readonly maxIterations: number; 105 | private readonly toolContextManager: ToolContextManager; 106 | private readonly toolMap: Map; 107 | 108 | constructor(options: AgentOptions) { 109 | this.callbacks = options.callbacks ?? {}; 110 | this.model = options.model; 111 | this.maxIterations = options.maxIterations ?? 5; 112 | this.toolContextManager = new ToolContextManager('.dexter/context', this.model); 113 | this.toolMap = new Map(TOOLS.map(t => [t.name, t])); 114 | } 115 | 116 | /** 117 | * Main entry point - runs the agent loop on a user query. 118 | */ 119 | async run(query: string, messageHistory?: MessageHistory): Promise { 120 | const summaries: ToolSummary[] = []; 121 | const queryId = this.toolContextManager.hashQuery(query); 122 | let finishReason: string | null = null; 123 | 124 | // Create finish tool with callback to capture finish reason 125 | const finishTool = createFinishTool((reason) => { 126 | finishReason = reason; 127 | }); 128 | 129 | // All tools available to the agent (including finish) 130 | const allTools = [...TOOLS, finishTool]; 131 | 132 | // Build tool schemas for the system prompt 133 | const toolSchemas = this.buildToolSchemas(); 134 | 135 | // Select relevant conversation history for context (done once at the start) 136 | let conversationContext: string | undefined; 137 | if (messageHistory && messageHistory.hasMessages()) { 138 | const relevantMessages = await messageHistory.selectRelevantMessages(query); 139 | if (relevantMessages.length > 0) { 140 | conversationContext = messageHistory.formatForPlanning(relevantMessages); 141 | } 142 | } 143 | 144 | // Main loop 145 | for (let i = 0; i < this.maxIterations; i++) { 146 | const iterationNum = i + 1; 147 | this.callbacks.onIterationStart?.(iterationNum); 148 | 149 | // Build the prompt for this iteration 150 | const systemPrompt = getSystemPrompt(toolSchemas); 151 | const userPrompt = buildUserPrompt(query, summaries, iterationNum, conversationContext); 152 | 153 | // Call LLM with tools bound 154 | const response = await callLlm(userPrompt, { 155 | systemPrompt, 156 | tools: allTools, 157 | model: this.model, 158 | }) as AIMessage; 159 | 160 | // Extract thinking from response content 161 | const thought = this.extractThought(response); 162 | if (thought) { 163 | this.callbacks.onThinking?.(thought); 164 | } 165 | 166 | // Check if agent called finish or has no more tool calls 167 | const toolCalls = response.tool_calls || []; 168 | 169 | // Check for finish tool call 170 | const finishCall = toolCalls.find(tc => tc.name === 'finish'); 171 | if (finishCall) { 172 | // Execute finish to capture the reason 173 | await finishTool.invoke(finishCall.args); 174 | this.callbacks.onIterationComplete?.(iterationNum); 175 | break; 176 | } 177 | 178 | // If no tool calls, agent is done (implicit finish) 179 | if (toolCalls.length === 0) { 180 | this.callbacks.onIterationComplete?.(iterationNum); 181 | break; 182 | } 183 | 184 | // Filter out finish calls from tool calls to execute 185 | const dataToolCalls = toolCalls.filter(tc => tc.name !== 'finish'); 186 | 187 | if (dataToolCalls.length > 0) { 188 | // Notify about tool calls starting 189 | const toolCallInfos: ToolCallInfo[] = dataToolCalls.map(tc => ({ 190 | name: tc.name, 191 | args: tc.args as Record, 192 | })); 193 | this.callbacks.onToolCallsStart?.(toolCallInfos); 194 | 195 | // Execute all tool calls in parallel 196 | const results = await Promise.all( 197 | dataToolCalls.map(async (toolCall) => { 198 | const toolName = toolCall.name; 199 | const args = toolCall.args as Record; 200 | 201 | try { 202 | const tool = this.toolMap.get(toolName); 203 | if (!tool) { 204 | throw new Error(`Tool not found: ${toolName}`); 205 | } 206 | 207 | const result = await tool.invoke(args); 208 | 209 | // Save to disk and get summary 210 | const summary = this.toolContextManager.saveAndGetSummary( 211 | toolName, 212 | args, 213 | result, 214 | queryId 215 | ); 216 | 217 | const callResult: ToolCallResult = { 218 | name: toolName, 219 | args, 220 | summary: summary.summary, 221 | success: true, 222 | }; 223 | this.callbacks.onToolCallComplete?.(callResult); 224 | 225 | return summary; 226 | } catch (error) { 227 | const callResult: ToolCallResult = { 228 | name: toolName, 229 | args, 230 | summary: `Error: ${error instanceof Error ? error.message : String(error)}`, 231 | success: false, 232 | }; 233 | this.callbacks.onToolCallComplete?.(callResult); 234 | return null; 235 | } 236 | }) 237 | ); 238 | 239 | // Add successful summaries to context 240 | for (const summary of results) { 241 | if (summary) { 242 | summaries.push(summary); 243 | } 244 | } 245 | } 246 | 247 | this.callbacks.onIterationComplete?.(iterationNum); 248 | } 249 | 250 | // Generate answer from collected data 251 | return this.generateAnswer(query, queryId, messageHistory); 252 | } 253 | 254 | /** 255 | * Extracts the thinking/reasoning from the LLM response content. 256 | */ 257 | private extractThought(response: AIMessage): string | null { 258 | const content = response.content; 259 | 260 | if (typeof content === 'string' && content.trim()) { 261 | return content.trim(); 262 | } 263 | 264 | // Handle array content (some models return this) 265 | if (Array.isArray(content)) { 266 | const textParts = content 267 | .filter((part): part is { type: 'text'; text: string } => 268 | typeof part === 'object' && part !== null && 'type' in part && part.type === 'text' 269 | ) 270 | .map(part => part.text); 271 | 272 | if (textParts.length > 0) { 273 | return textParts.join('\n').trim(); 274 | } 275 | } 276 | 277 | return null; 278 | } 279 | 280 | /** 281 | * Builds tool schemas string for the system prompt. 282 | */ 283 | private buildToolSchemas(): string { 284 | return TOOLS.map((tool) => { 285 | const jsonSchema = tool.schema as Record; 286 | const properties = (jsonSchema.properties as Record) || {}; 287 | const required = (jsonSchema.required as string[]) || []; 288 | 289 | const paramLines = Object.entries(properties).map(([name, prop]) => { 290 | const propObj = prop as { type?: string; description?: string; enum?: string[]; default?: unknown }; 291 | const isRequired = required.includes(name); 292 | const reqLabel = isRequired ? ' (required)' : ''; 293 | const enumValues = propObj.enum ? ` [${propObj.enum.join(', ')}]` : ''; 294 | const defaultVal = propObj.default !== undefined ? ` default=${propObj.default}` : ''; 295 | return ` - ${name}: ${propObj.type || 'any'}${enumValues}${reqLabel}${defaultVal} - ${propObj.description || ''}`; 296 | }); 297 | 298 | return `${tool.name}: ${tool.description} 299 | Parameters: 300 | ${paramLines.join('\n')}`; 301 | }).join('\n\n'); 302 | } 303 | 304 | /** 305 | * Generates the final answer by selecting and loading relevant contexts. 306 | */ 307 | private async generateAnswer( 308 | query: string, 309 | queryId: string, 310 | messageHistory?: MessageHistory 311 | ): Promise { 312 | this.callbacks.onAnswerStart?.(); 313 | 314 | const pointers = this.toolContextManager.getPointersForQuery(queryId); 315 | 316 | // Build conversation context from message history 317 | let conversationContext = ''; 318 | if (messageHistory && messageHistory.hasMessages()) { 319 | const relevantMessages = await messageHistory.selectRelevantMessages(query); 320 | if (relevantMessages.length > 0) { 321 | const formattedHistory = messageHistory.formatForAnswerGeneration(relevantMessages); 322 | conversationContext = `Previous conversation context (for reference): 323 | ${formattedHistory} 324 | 325 | --- 326 | 327 | `; 328 | } 329 | } 330 | 331 | if (pointers.length === 0) { 332 | // No data collected - generate answer without tool data 333 | const stream = await this.generateNoDataAnswer(query, conversationContext); 334 | this.callbacks.onAnswerStream?.(stream); 335 | return ''; 336 | } 337 | 338 | // Select relevant contexts using LLM 339 | const selectedFilepaths = await this.toolContextManager.selectRelevantContexts(query, pointers); 340 | 341 | // Load the full context data 342 | const selectedContexts = this.toolContextManager.loadContexts(selectedFilepaths); 343 | 344 | if (selectedContexts.length === 0) { 345 | const stream = await this.generateNoDataAnswer(query, conversationContext); 346 | this.callbacks.onAnswerStream?.(stream); 347 | return ''; 348 | } 349 | 350 | // Format contexts for the prompt 351 | const formattedResults = selectedContexts.map(ctx => { 352 | const toolName = ctx.toolName || 'unknown'; 353 | const args = ctx.args || {}; 354 | const result = ctx.result; 355 | const sourceUrls = ctx.sourceUrls || []; 356 | const sourceLine = sourceUrls.length > 0 ? `\nSource URLs: ${sourceUrls.join(', ')}` : ''; 357 | return `Output of ${toolName} with args ${JSON.stringify(args)}:${sourceLine}\n${JSON.stringify(result, null, 2)}`; 358 | }); 359 | 360 | // Collect all available sources for reference 361 | const allSources = selectedContexts 362 | .filter(ctx => ctx.sourceUrls && ctx.sourceUrls.length > 0) 363 | .map(ctx => ({ 364 | toolDescription: ctx.toolDescription || ctx.toolName, 365 | urls: ctx.sourceUrls!, 366 | })); 367 | 368 | const allResults = formattedResults.join('\n\n'); 369 | 370 | const prompt = `${conversationContext}Original user query: "${query}" 371 | 372 | Data and results collected from tools: 373 | ${allResults} 374 | 375 | ${allSources.length > 0 ? `Available sources for citation:\n${JSON.stringify(allSources, null, 2)}\n\n` : ''}Based on the data above, provide a comprehensive answer to the user's query. 376 | Include specific numbers, calculations, and insights.`; 377 | 378 | const stream = callLlmStream(prompt, { 379 | systemPrompt: getAnswerSystemPrompt(), 380 | model: this.model, 381 | }); 382 | 383 | this.callbacks.onAnswerStream?.(stream); 384 | return ''; 385 | } 386 | 387 | /** 388 | * Generates a streaming answer when no data was collected. 389 | */ 390 | private async generateNoDataAnswer( 391 | query: string, 392 | conversationContext: string = '' 393 | ): Promise> { 394 | const prompt = `${conversationContext}Original user query: "${query}" 395 | 396 | No data was collected from tools. Answer the query using your general knowledge, or explain what information would be needed.`; 397 | 398 | return callLlmStream(prompt, { 399 | systemPrompt: getAnswerSystemPrompt(), 400 | model: this.model, 401 | }); 402 | } 403 | } 404 | --------------------------------------------------------------------------------