├── 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 |
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 | [](https://twitter.com/virattt)
20 |
21 |
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 |
--------------------------------------------------------------------------------