diff --git a/packages/core/src/agent/research/finance-decisioner.test.ts b/packages/core/src/agent/research/finance-decisioner.test.ts new file mode 100644 index 00000000..af7daec4 --- /dev/null +++ b/packages/core/src/agent/research/finance-decisioner.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildInternalFinanceGuidance, decideFinanceEvidencePlan } from "./finance-decisioner.js"; + +describe("decideFinanceEvidencePlan", () => { + it("returns undefined for non-finance prompts", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Write a TypeScript utility to parse CSV files.", + tools: ["read", "write", "exec"], + }); + expect(result).toBeUndefined(); + }); + + it("prefers data_only for secondary market non-event tasks", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Analyze AAPL valuation based on 5-year financial statements.", + tools: ["data", "web_search", "web_fetch"], + }); + expect(result).toBeDefined(); + expect(result?.plan).toBe("data_only"); + expect(result?.marketRoute).toBe("secondary"); + expect(result?.reasons).toContain("secondary_market_task"); + }); + + it("prefers hybrid for event-driven secondary tasks", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Why did AAPL drop after latest earnings and guidance update?", + tools: ["data", "web_search", "web_fetch"], + }); + expect(result).toBeDefined(); + expect(result?.plan).toBe("hybrid"); + expect(result?.reasons).toContain("event_driven"); + expect(result?.reasons).toContain("causal_explanation_needed"); + }); + + it("prefers web_first for primary market tasks without ticker", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Review this pre-IPO issuance structure and lock-up risks.", + tools: ["data", "web_search", "web_fetch"], + }); + expect(result).toBeDefined(); + expect(result?.marketRoute).toBe("primary_no_ticker"); + expect(result?.plan).toBe("web_first"); + }); + + it("degrades when web tools are unavailable for event-driven tasks", () => { + const result = decideFinanceEvidencePlan({ + prompt: "Analyze latest earnings surprise drivers for TSLA stock.", + tools: ["data"], + }); + expect(result).toBeDefined(); + expect(result?.reasons).toContain("web_tools_unavailable"); + expect(result?.confidencePenalty).toBe("high"); + }); +}); + +describe("buildInternalFinanceGuidance", () => { + it("formats internal guidance text", () => { + const decision = decideFinanceEvidencePlan({ + prompt: "Analyze latest AAPL earnings impact on valuation.", + tools: ["data", "web_search"], + }); + expect(decision).toBeDefined(); + const guidance = buildInternalFinanceGuidance(decision!); + expect(guidance).toContain("Internal Finance Research Guidance"); + expect(guidance).toContain("Preferred evidence plan:"); + expect(guidance).toContain("Do not expose technical labels"); + }); +}); diff --git a/packages/core/src/agent/research/finance-decisioner.ts b/packages/core/src/agent/research/finance-decisioner.ts new file mode 100644 index 00000000..7df1be8d --- /dev/null +++ b/packages/core/src/agent/research/finance-decisioner.ts @@ -0,0 +1,304 @@ +/** + * Finance evidence decisioner. + * + * Produces an internal research plan for finance tasks: + * - data_only + * - hybrid (data + web validation) + * - web_first + * + * The output is intended for internal orchestration only. + */ + +export type FinanceEvidencePlan = "data_only" | "hybrid" | "web_first"; + +export type FinanceMarketRoute = "secondary" | "primary_with_ticker" | "primary_no_ticker"; + +export type FinanceConfidencePenalty = "low" | "medium" | "high"; + +export interface FinanceDecisionInput { + prompt: string; + tools: string[]; +} + +export interface FinanceDecision { + plan: FinanceEvidencePlan; + marketRoute: FinanceMarketRoute; + confidencePenalty: FinanceConfidencePenalty; + reasons: string[]; + score: Record; +} + +const FINANCE_KEYWORDS = [ + "stock", + "stocks", + "equity", + "equities", + "valuation", + "financial", + "finance", + "earnings", + "revenue", + "eps", + "cash flow", + "balance sheet", + "income statement", + "pe ratio", + "market cap", + "ipo", + "pre-ipo", + "listing", + "ticker", + "一级市场", + "二级市场", + "财报", + "股票", + "估值", + "市值", + "募资", + "锁定期", + "稀释", +]; + +const PRIMARY_MARKET_KEYWORDS = [ + "ipo", + "pre-ipo", + "prospectus", + "s-1", + "f-1", + "roadshow", + "listing", + "follow-on", + "new issuance", + "lock-up", + "dilution", + "一级市场", + "募资", + "锁定期", + "稀释", +]; + +const EVENT_DRIVEN_KEYWORDS = [ + "latest", + "recent", + "today", + "yesterday", + "breaking", + "earnings call", + "guidance", + "surprise", + "selloff", + "policy", + "fed", + "fomc", + "news", + "headline", + "突发", + "最新", + "消息", + "政策", + "财报后", +]; + +const CAUSAL_KEYWORDS = [ + "why", + "reason", + "driver", + "impact", + "because", + "attribution", + "explain", + "原因", + "驱动", + "影响", + "为什么", +]; + +const TIME_SENSITIVE_KEYWORDS = [ + "latest", + "today", + "this week", + "this month", + "current", + "now", + "最新", + "当前", + "近期", +]; + +const COMMON_UPPERCASE_NON_TICKERS = new Set([ + "IPO", + "SEC", + "USD", + "CNY", + "HKD", + "GDP", + "CPI", + "PPI", + "FED", + "FOMC", + "EPS", + "FCF", + "PE", + "TTM", + "DCF", +]); + +function includesAny(text: string, keywords: string[]): boolean { + return keywords.some((keyword) => text.includes(keyword)); +} + +function normalizeTools(tools: string[]): Set { + return new Set(tools.map((tool) => tool.toLowerCase())); +} + +function hasTickerSignal(prompt: string): boolean { + const explicit = /(?:\$|ticker\s*[:=]\s*)([A-Za-z]{1,6})/g; + if (explicit.test(prompt)) return true; + + const upperWords = prompt.match(/\b[A-Z]{1,6}\b/g) ?? []; + const candidates = upperWords.filter((word) => !COMMON_UPPERCASE_NON_TICKERS.has(word)); + return candidates.length > 0; +} + +function isFinanceTask(prompt: string): boolean { + const normalized = prompt.toLowerCase(); + return includesAny(normalized, FINANCE_KEYWORDS); +} + +function resolveMarketRoute(prompt: string): FinanceMarketRoute { + const normalized = prompt.toLowerCase(); + const primary = includesAny(normalized, PRIMARY_MARKET_KEYWORDS); + if (!primary) return "secondary"; + return hasTickerSignal(prompt) ? "primary_with_ticker" : "primary_no_ticker"; +} + +function choosePlan(score: Record): FinanceEvidencePlan { + const order: FinanceEvidencePlan[] = ["data_only", "hybrid", "web_first"]; + let best: FinanceEvidencePlan = order[0]; + for (const plan of order) { + if (score[plan] > score[best]) best = plan; + } + return best; +} + +function resolveConfidencePenalty(params: { + plan: FinanceEvidencePlan; + hasData: boolean; + hasWeb: boolean; + route: FinanceMarketRoute; + eventDriven: boolean; + timeSensitive: boolean; +}): FinanceConfidencePenalty { + const { plan, hasData, hasWeb, route, eventDriven, timeSensitive } = params; + + if (!hasData && !hasWeb) return "high"; + if ((plan === "hybrid" || plan === "web_first") && !hasWeb) return "high"; + if (plan === "data_only" && (eventDriven || timeSensitive) && !hasWeb) return "high"; + if (route === "primary_no_ticker") return "medium"; + if (plan === "data_only" && (eventDriven || timeSensitive)) return "medium"; + return "low"; +} + +export function decideFinanceEvidencePlan(input: FinanceDecisionInput): FinanceDecision | undefined { + const { prompt } = input; + if (!isFinanceTask(prompt)) return undefined; + + const normalized = prompt.toLowerCase(); + const toolSet = normalizeTools(input.tools); + const hasData = toolSet.has("data"); + const hasWebSearch = toolSet.has("web_search"); + const hasWebFetch = toolSet.has("web_fetch"); + const hasWeb = hasWebSearch || hasWebFetch; + + const route = resolveMarketRoute(prompt); + const eventDriven = includesAny(normalized, EVENT_DRIVEN_KEYWORDS); + const causal = includesAny(normalized, CAUSAL_KEYWORDS); + const timeSensitive = includesAny(normalized, TIME_SENSITIVE_KEYWORDS); + + const score: Record = { + data_only: hasData ? 1.0 : -3.0, + hybrid: hasData && hasWeb ? 1.0 : -2.0, + web_first: hasWeb ? 0.6 : -3.0, + }; + + const reasons: string[] = []; + + if (route === "secondary") { + score.data_only += 0.7; + score.hybrid += 0.4; + reasons.push("secondary_market_task"); + } else if (route === "primary_with_ticker") { + score.hybrid += 0.9; + score.web_first += 0.3; + score.data_only -= 0.2; + reasons.push("primary_market_with_ticker"); + } else { + score.web_first += 1.3; + score.hybrid += 0.7; + score.data_only -= 1.0; + reasons.push("primary_market_without_ticker"); + } + + if (eventDriven) { + score.hybrid += 0.9; + score.web_first += 0.4; + score.data_only -= 0.5; + reasons.push("event_driven"); + } + + if (timeSensitive) { + score.hybrid += 0.6; + score.web_first += 0.3; + score.data_only -= 0.4; + reasons.push("time_sensitive"); + } + + if (causal) { + score.hybrid += 0.4; + score.web_first += 0.2; + score.data_only -= 0.2; + reasons.push("causal_explanation_needed"); + } + + if (!hasWeb) { + score.hybrid -= 2.0; + score.web_first -= 3.0; + reasons.push("web_tools_unavailable"); + } + if (!hasData) { + score.data_only -= 2.5; + score.hybrid -= 1.5; + score.web_first += 0.5; + reasons.push("data_tool_unavailable"); + } + + const plan = choosePlan(score); + const confidencePenalty = resolveConfidencePenalty({ + plan, + hasData, + hasWeb, + route, + eventDriven, + timeSensitive, + }); + + return { + plan, + marketRoute: route, + confidencePenalty, + reasons, + score, + }; +} + +export function buildInternalFinanceGuidance(decision: FinanceDecision): string { + return [ + "## Internal Finance Research Guidance", + "This section is internal orchestration guidance. Do not expose technical labels directly to the user unless they explicitly request methodology details.", + `Preferred evidence plan: ${decision.plan}`, + `Market route: ${decision.marketRoute}`, + `Confidence penalty if evidence gaps remain: ${decision.confidencePenalty}`, + `Decision factors: ${decision.reasons.join(", ") || "none"}`, + "Execution policy: start with the preferred plan, then escalate evidence collection if signals conflict or causality remains unresolved.", + ].join("\n"); +} diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index beaa4faf..8ea675f3 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -35,8 +35,12 @@ import { import { buildSystemPrompt as buildStructuredSystemPrompt, collectRuntimeInfo, - type SystemPromptMode, } from "./system-prompt/index.js"; +import { + buildInternalFinanceGuidance, + decideFinanceEvidencePlan, + type FinanceDecision, +} from "./research/finance-decisioner.js"; import type { AuthProfileFailureReason } from "./auth-profiles/index.js"; import { sanitizeToolCallInputs, @@ -425,6 +429,7 @@ export class Agent { ): Promise { await this.ensureInitialized(); this.refreshAuthState(); + this.applyFinanceResearchGuidance(prompt); this.output.state.lastAssistantText = ""; this.currentUserDisplayPrompt = options?.displayPrompt; @@ -968,6 +973,13 @@ export class Agent { * Shared by constructor (via buildFullSystemPrompt) and reloadSystemPrompt. */ private rebuildSystemPrompt(toolNames: string[]): string | undefined { + return this.rebuildSystemPromptWithExtra(toolNames); + } + + private rebuildSystemPromptWithExtra( + toolNames: string[], + extraSystemPrompt?: string | undefined, + ): string | undefined { const profile = this.profile?.getProfile(); if (!profile) return undefined; @@ -993,6 +1005,40 @@ export class Agent { tools: toolNames, skillsPrompt, runtime, + extraSystemPrompt, + }); + } + + private applyFinanceResearchGuidance(prompt: string): void { + const toolNames = (this.agent.state.tools ?? []).map((t: { name: string }) => t.name); + const decision = decideFinanceEvidencePlan({ prompt, tools: toolNames }); + + const guidance = decision ? buildInternalFinanceGuidance(decision) : undefined; + const systemPrompt = this.rebuildSystemPromptWithExtra(toolNames, guidance); + if (systemPrompt) { + this.agent.setSystemPrompt(systemPrompt); + this.session.setSystemPrompt(systemPrompt); + } + + this.saveFinanceDecisionMeta(decision); + } + + private saveFinanceDecisionMeta(decision: FinanceDecision | undefined): void { + const currentMeta = this.session.getMeta() ?? {}; + if (!decision) { + // Keep previously saved decisions for non-finance turns. + return; + } + this.session.saveMeta({ + ...currentMeta, + researchDecision: { + domain: "finance", + plan: decision.plan, + marketRoute: decision.marketRoute, + confidencePenalty: decision.confidencePenalty, + reasons: decision.reasons, + timestamp: Date.now(), + }, }); } } diff --git a/packages/core/src/agent/session/types.ts b/packages/core/src/agent/session/types.ts index ec734424..48c037a9 100644 --- a/packages/core/src/agent/session/types.ts +++ b/packages/core/src/agent/session/types.ts @@ -9,6 +9,15 @@ export type SessionMeta = { reasoningMode?: string; /** Context window token 数 */ contextWindowTokens?: number; + /** Internal finance evidence decision from the latest run */ + researchDecision?: { + domain: "finance"; + plan: "data_only" | "hybrid" | "web_first"; + marketRoute: "secondary" | "primary_with_ticker" | "primary_no_ticker"; + confidencePenalty: "low" | "medium" | "high"; + reasons: string[]; + timestamp: number; + } | undefined; }; export type SessionEntry =