diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index f0db6e330..6d696e7d0 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -28,6 +28,7 @@ const CORE_TOOL_SUMMARIES: Record = { web_fetch: "Fetch and extract readable content from a URL", memory_search: "Search memory files by keyword", sessions_spawn: "Spawn a sub-agent session", + data: "Query structured financial and market data", }; /** Preferred display order for tools */ @@ -42,6 +43,7 @@ const TOOL_ORDER = [ "web_fetch", "memory_search", "sessions_spawn", + "data", ]; // ─── Section builders ─────────────────────────────────────────────────────── @@ -266,6 +268,17 @@ export function buildConditionalToolSections( ); } + // Data tools + if (toolSet.has("data")) { + lines.push( + "## Data Access", + "You have access to structured financial and market data via the `data` tool.", + 'Use domain="finance" with specific actions to retrieve stock prices, financial statements, SEC filings, metrics, and more.', + "Always specify dates in YYYY-MM-DD format. Use period='annual' or 'quarterly' or 'ttm' for financial statements.", + "", + ); + } + // Web tools if (toolSet.has("web_search") || toolSet.has("web_fetch")) { lines.push( diff --git a/packages/core/src/agent/tools.ts b/packages/core/src/agent/tools.ts index f3908d736..7905fc743 100644 --- a/packages/core/src/agent/tools.ts +++ b/packages/core/src/agent/tools.ts @@ -10,6 +10,7 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn.js"; import { createSessionsListTool } from "./tools/sessions-list.js"; import { createMemorySearchTool } from "./tools/memory-search.js"; import { createCronTool } from "./tools/cron/index.js"; +import { createDataTool } from "./tools/data/index.js"; import { filterTools } from "./tools/policy.js"; import { isMulticaError, isRetryableError } from "@multica/utils"; import type { ExecApprovalCallback } from "./tools/exec-approval-types.js"; @@ -110,6 +111,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< const webSearchTool = createWebSearchTool(); const cronTool = createCronTool(); + const dataTool = createDataTool(); const tools: AgentTool[] = [ ...baseTools, @@ -119,6 +121,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< webFetchTool as AgentTool, webSearchTool as AgentTool, cronTool as AgentTool, + dataTool as AgentTool, ]; // Add memory_search tool if profileDir is provided diff --git a/packages/core/src/agent/tools/data/data-tool.ts b/packages/core/src/agent/tools/data/data-tool.ts new file mode 100644 index 000000000..b4bb2e997 --- /dev/null +++ b/packages/core/src/agent/tools/data/data-tool.ts @@ -0,0 +1,118 @@ +/** + * Unified data tool — structured access to domain-specific data sources. + * + * Currently supports the "finance" domain (Financial Datasets API). + * Designed as a stable interface: the backend can be swapped from + * direct API calls to a Multica Data Service without changing the tool schema. + */ + +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { executeFinanceAction } from "./finance/actions.js"; + +// ─── Schema ───────────────────────────────────────────────────────────────── + +const DataToolSchema = Type.Object({ + domain: Type.String({ + description: 'Data domain. Currently supported: "finance".', + }), + action: Type.String({ + description: + "Action to perform within the domain.\n\n" + + "FINANCE DOMAIN ACTIONS:\n" + + "Prices:\n" + + " get_price_snapshot — params: { ticker }\n" + + " get_prices — params: { ticker, start_date, end_date, interval?, interval_multiplier? }\n" + + " get_crypto_price_snapshot — params: { ticker } (e.g. BTC-USD)\n" + + " get_crypto_prices — params: { ticker, start_date, end_date, interval?, interval_multiplier? }\n" + + " get_available_crypto_tickers — params: {}\n" + + "Financial Statements (period: annual|quarterly|ttm):\n" + + " get_income_statements — params: { ticker, period, limit?, report_period_gt/gte/lt/lte? }\n" + + " get_balance_sheets — same params\n" + + " get_cash_flow_statements — same params\n" + + " get_all_financial_statements — same params (returns all three)\n" + + "Metrics:\n" + + " get_financial_metrics_snapshot — params: { ticker }\n" + + " get_financial_metrics — params: { ticker, period?, limit?, report_period*? }\n" + + " get_analyst_estimates — params: { ticker, period? }\n" + + "Company:\n" + + " get_news — params: { ticker, start_date?, end_date?, limit? }\n" + + " get_insider_trades — params: { ticker, limit?, filing_date*? }\n" + + " get_segmented_revenues — params: { ticker, period, limit? }\n" + + " get_company_facts — params: { ticker }\n" + + "SEC Filings:\n" + + " get_filings — params: { ticker, filing_type?, limit? }\n" + + " get_filing_items — params: { ticker, filing_type, accession_number?, item? }", + }), + params: Type.Record(Type.String(), Type.Unknown(), { + description: + "Action-specific parameters as key-value pairs. " + + "Common: ticker (string, e.g. 'AAPL'), period ('annual'|'quarterly'|'ttm'), " + + "limit (number), start_date/end_date ('YYYY-MM-DD'), " + + "interval ('day'|'week'|'month'|'year'), filing_type ('10-K'|'10-Q'|'8-K').", + }), +}); + +// ─── Types ────────────────────────────────────────────────────────────────── + +type DataToolArgs = { + domain: string; + action: string; + params: Record; +}; + +export type DataToolResult = { + domain: string; + action: string; + data: unknown; + sourceUrl?: string; +}; + +// ─── Factory ──────────────────────────────────────────────────────────────── + +export function createDataTool(): AgentTool { + return { + name: "data", + label: "Data", + description: + "Query structured data from external sources. " + + 'Supports domain="finance" for stock prices, financial statements, key metrics, ' + + "SEC filings, analyst estimates, insider trades, news, and crypto data.", + parameters: DataToolSchema, + execute: async (_toolCallId, args, signal) => { + const { domain, action, params } = args as DataToolArgs; + + if (domain !== "finance") { + const errorPayload = { + error: true, + message: `Unknown domain: "${domain}". Currently supported: "finance".`, + }; + return { + content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], + details: { domain, action, data: null } as unknown as DataToolResult, + }; + } + + try { + const result = await executeFinanceAction(action, params ?? {}, signal); + const payload: DataToolResult = { + domain, + action, + data: result.data, + sourceUrl: result.sourceUrl, + }; + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + details: payload, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const errorPayload = { error: true, domain, action, message }; + return { + content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], + details: { domain, action, data: null } as unknown as DataToolResult, + }; + } + }, + }; +} diff --git a/packages/core/src/agent/tools/data/finance/actions.ts b/packages/core/src/agent/tools/data/finance/actions.ts new file mode 100644 index 000000000..557b9fcf1 --- /dev/null +++ b/packages/core/src/agent/tools/data/finance/actions.ts @@ -0,0 +1,295 @@ +/** + * Finance action handlers. + * + * Maps each action name to the corresponding Financial Datasets API call. + */ + +import type { FinanceAction } from "./types.js"; +import { FINANCE_ACTION_SET } from "./types.js"; +import { financeFetch } from "./api.js"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +type Params = Record; + +function requireParam(params: Params, name: string): string { + const value = params[name]; + if (value === undefined || value === null || value === "") { + throw new Error(`Missing required parameter: ${name}`); + } + return String(value); +} + +function optionalString(params: Params, name: string): string | undefined { + const value = params[name]; + if (value === undefined || value === null || value === "") return undefined; + return String(value); +} + +function optionalNumber(params: Params, name: string): number | undefined { + const value = params[name]; + if (value === undefined || value === null) return undefined; + return Number(value); +} + +function optionalStringArray(params: Params, name: string): string[] | undefined { + const value = params[name]; + if (value === undefined || value === null) return undefined; + if (Array.isArray(value)) return value.map(String); + return [String(value)]; +} + +/** Extract common financial statement filter params */ +function statementFilters(params: Params) { + return { + ticker: requireParam(params, "ticker"), + period: requireParam(params, "period"), + limit: optionalNumber(params, "limit"), + report_period_gt: optionalString(params, "report_period_gt"), + report_period_gte: optionalString(params, "report_period_gte"), + report_period_lt: optionalString(params, "report_period_lt"), + report_period_lte: optionalString(params, "report_period_lte"), + }; +} + +// ─── Action result type ───────────────────────────────────────────────────── + +export interface FinanceActionResult { + data: unknown; + sourceUrl: string; +} + +// ─── Action handlers ──────────────────────────────────────────────────────── + +type ActionHandler = (params: Params, signal?: AbortSignal) => Promise; + +const handlers: Record = { + // ── Prices ────────────────────────────────────────────────────────────── + + get_price_snapshot: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch("/prices/snapshot", { ticker }, signal); + return { data: (data as Record).snapshot ?? data, sourceUrl: url }; + }, + + get_prices: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const start_date = requireParam(params, "start_date"); + const end_date = requireParam(params, "end_date"); + const { data, url } = await financeFetch( + "/prices", + { + ticker, + start_date, + end_date, + interval: optionalString(params, "interval") ?? "day", + interval_multiplier: optionalNumber(params, "interval_multiplier"), + }, + signal, + ); + return { data: (data as Record).prices ?? data, sourceUrl: url }; + }, + + get_crypto_price_snapshot: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch("/crypto/prices/snapshot", { ticker }, signal); + return { data: (data as Record).snapshot ?? data, sourceUrl: url }; + }, + + get_crypto_prices: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const start_date = requireParam(params, "start_date"); + const end_date = requireParam(params, "end_date"); + const { data, url } = await financeFetch( + "/crypto/prices", + { + ticker, + start_date, + end_date, + interval: optionalString(params, "interval") ?? "day", + interval_multiplier: optionalNumber(params, "interval_multiplier"), + }, + signal, + ); + return { data: (data as Record).prices ?? data, sourceUrl: url }; + }, + + get_available_crypto_tickers: async (_params, signal) => { + const { data, url } = await financeFetch("/crypto/prices/tickers", {}, signal); + return { data: (data as Record).tickers ?? data, sourceUrl: url }; + }, + + // ── Financial statements ──────────────────────────────────────────────── + + get_income_statements: async (params, signal) => { + const filters = statementFilters(params); + const { data, url } = await financeFetch("/financials/income-statements", filters, signal); + return { data: (data as Record).income_statements ?? data, sourceUrl: url }; + }, + + get_balance_sheets: async (params, signal) => { + const filters = statementFilters(params); + const { data, url } = await financeFetch("/financials/balance-sheets", filters, signal); + return { data: (data as Record).balance_sheets ?? data, sourceUrl: url }; + }, + + get_cash_flow_statements: async (params, signal) => { + const filters = statementFilters(params); + const { data, url } = await financeFetch("/financials/cash-flow-statements", filters, signal); + return { data: (data as Record).cash_flow_statements ?? data, sourceUrl: url }; + }, + + get_all_financial_statements: async (params, signal) => { + const filters = statementFilters(params); + const { data, url } = await financeFetch("/financials", filters, signal); + return { data: (data as Record).financials ?? data, sourceUrl: url }; + }, + + // ── Metrics & estimates ───────────────────────────────────────────────── + + get_financial_metrics_snapshot: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch("/financial-metrics/snapshot", { ticker }, signal); + return { data: (data as Record).snapshot ?? data, sourceUrl: url }; + }, + + get_financial_metrics: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/financial-metrics", + { + ticker, + period: optionalString(params, "period"), + limit: optionalNumber(params, "limit"), + report_period: optionalString(params, "report_period"), + report_period_gt: optionalString(params, "report_period_gt"), + report_period_gte: optionalString(params, "report_period_gte"), + report_period_lt: optionalString(params, "report_period_lt"), + report_period_lte: optionalString(params, "report_period_lte"), + }, + signal, + ); + return { data: (data as Record).financial_metrics ?? data, sourceUrl: url }; + }, + + get_analyst_estimates: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/analyst-estimates", + { + ticker, + period: optionalString(params, "period"), + }, + signal, + ); + return { data: (data as Record).analyst_estimates ?? data, sourceUrl: url }; + }, + + // ── Company info ──────────────────────────────────────────────────────── + + get_news: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/news", + { + ticker, + start_date: optionalString(params, "start_date"), + end_date: optionalString(params, "end_date"), + limit: optionalNumber(params, "limit"), + }, + signal, + ); + return { data: (data as Record).news ?? data, sourceUrl: url }; + }, + + get_insider_trades: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/insider-trades", + { + ticker: ticker.toUpperCase(), + limit: optionalNumber(params, "limit"), + filing_date: optionalString(params, "filing_date"), + filing_date_gt: optionalString(params, "filing_date_gt"), + filing_date_gte: optionalString(params, "filing_date_gte"), + filing_date_lt: optionalString(params, "filing_date_lt"), + filing_date_lte: optionalString(params, "filing_date_lte"), + }, + signal, + ); + return { data: (data as Record).insider_trades ?? data, sourceUrl: url }; + }, + + get_segmented_revenues: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const period = requireParam(params, "period"); + const { data, url } = await financeFetch( + "/financials/segmented-revenues", + { + ticker, + period, + limit: optionalNumber(params, "limit"), + }, + signal, + ); + return { data: (data as Record).segmented_revenues ?? data, sourceUrl: url }; + }, + + get_company_facts: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch("/company/facts", { ticker }, signal); + return { data: (data as Record).company_facts ?? data, sourceUrl: url }; + }, + + // ── SEC filings ───────────────────────────────────────────────────────── + + get_filings: async (params, signal) => { + const ticker = requireParam(params, "ticker"); + const { data, url } = await financeFetch( + "/filings", + { + ticker, + filing_type: optionalString(params, "filing_type"), + limit: optionalNumber(params, "limit"), + }, + signal, + ); + return { data: (data as Record).filings ?? data, sourceUrl: url }; + }, + + get_filing_items: async (params, signal) => { + const ticker = requireParam(params, "ticker").toUpperCase(); + const filing_type = requireParam(params, "filing_type"); + const { data, url } = await financeFetch( + "/filings/items", + { + ticker, + filing_type, + accession_number: optionalString(params, "accession_number"), + item: optionalStringArray(params, "item"), + }, + signal, + ); + return { data, sourceUrl: url }; + }, +}; + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Execute a finance domain action. + * + * @throws Error if the action is unknown or required params are missing. + */ +export function executeFinanceAction( + action: string, + params: Record, + signal?: AbortSignal, +): Promise { + if (!FINANCE_ACTION_SET.has(action)) { + throw new Error( + `Unknown finance action: "${action}". Available: ${[...FINANCE_ACTION_SET].join(", ")}`, + ); + } + return handlers[action as FinanceAction](params, signal); +} diff --git a/packages/core/src/agent/tools/data/finance/api.ts b/packages/core/src/agent/tools/data/finance/api.ts new file mode 100644 index 000000000..c775291b1 --- /dev/null +++ b/packages/core/src/agent/tools/data/finance/api.ts @@ -0,0 +1,74 @@ +/** + * Financial Datasets API client. + * + * Base URL: https://api.financialdatasets.ai + * Auth: X-API-KEY header + * All endpoints use GET with query parameters. + */ + +import { credentialManager } from "../../../credentials.js"; + +const BASE_URL = "https://api.financialdatasets.ai"; +const TIMEOUT_MS = 30_000; + +function getApiKey(): string { + const key = credentialManager.getEnv("FINANCIAL_DATASETS_API_KEY"); + if (!key) { + throw new Error( + "FINANCIAL_DATASETS_API_KEY not configured. " + + "Set it in ~/.super-multica/skills.env.json5 under env, or as an environment variable.", + ); + } + return key; +} + +/** + * Fetch data from the Financial Datasets API. + * + * @param path - API path (e.g., "/prices/snapshot") + * @param params - Query parameters. Arrays are sent as repeated params (e.g., item=1A&item=1B). + * @param signal - Optional AbortSignal for cancellation. + */ +export async function financeFetch>( + path: string, + params: Record, + signal?: AbortSignal, +): Promise<{ data: T; url: string }> { + const apiKey = getApiKey(); + + const url = new URL(path, BASE_URL); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const v of value) { + url.searchParams.append(key, String(v)); + } + } else { + url.searchParams.set(key, String(value)); + } + } + + const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS); + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + + const res = await fetch(url.toString(), { + method: "GET", + headers: { + "X-API-KEY": apiKey, + Accept: "application/json", + }, + signal: combinedSignal, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Financial Datasets API error (${res.status}): ${body || res.statusText}`, + ); + } + + const data = (await res.json()) as T; + return { data, url: url.toString() }; +} diff --git a/packages/core/src/agent/tools/data/finance/types.ts b/packages/core/src/agent/tools/data/finance/types.ts new file mode 100644 index 000000000..82fa4342d --- /dev/null +++ b/packages/core/src/agent/tools/data/finance/types.ts @@ -0,0 +1,35 @@ +/** + * Finance domain types for the data tool. + */ + +/** All supported finance actions */ +export const FINANCE_ACTIONS = [ + // Price data + "get_price_snapshot", + "get_prices", + "get_crypto_price_snapshot", + "get_crypto_prices", + "get_available_crypto_tickers", + // Financial statements + "get_income_statements", + "get_balance_sheets", + "get_cash_flow_statements", + "get_all_financial_statements", + // Metrics & estimates + "get_financial_metrics_snapshot", + "get_financial_metrics", + "get_analyst_estimates", + // Company info + "get_news", + "get_insider_trades", + "get_segmented_revenues", + "get_company_facts", + // SEC filings + "get_filings", + "get_filing_items", +] as const; + +export type FinanceAction = (typeof FINANCE_ACTIONS)[number]; + +/** Set for O(1) lookup */ +export const FINANCE_ACTION_SET = new Set(FINANCE_ACTIONS); diff --git a/packages/core/src/agent/tools/data/index.ts b/packages/core/src/agent/tools/data/index.ts new file mode 100644 index 000000000..7712b0813 --- /dev/null +++ b/packages/core/src/agent/tools/data/index.ts @@ -0,0 +1 @@ +export { createDataTool, type DataToolResult } from "./data-tool.js"; diff --git a/packages/core/src/agent/tools/groups.ts b/packages/core/src/agent/tools/groups.ts index f61c9037a..a2bbed126 100644 --- a/packages/core/src/agent/tools/groups.ts +++ b/packages/core/src/agent/tools/groups.ts @@ -39,6 +39,9 @@ export const TOOL_GROUPS: Record = { // Cron/scheduling tools "group:cron": ["cron"], + // Data tools (finance, etc.) + "group:data": ["data"], + // All core tools "group:core": [ "read", @@ -49,6 +52,7 @@ export const TOOL_GROUPS: Record = { "process", "web_search", "web_fetch", + "data", ], }; diff --git a/packages/core/src/agent/tools/index.ts b/packages/core/src/agent/tools/index.ts index e225c54cd..3e148e1a9 100644 --- a/packages/core/src/agent/tools/index.ts +++ b/packages/core/src/agent/tools/index.ts @@ -8,6 +8,7 @@ export { createProcessTool } from "./process.js"; export { createGlobTool } from "./glob.js"; export { createWebFetchTool, createWebSearchTool } from "./web/index.js"; export { createCronTool } from "./cron/index.js"; +export { createDataTool } from "./data/index.js"; export { createSessionsListTool } from "./sessions-list.js"; // Tool groups