Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/src/capability.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TSchema } from '@sinclair/typebox';
import type { FinancialEvidenceMetadata } from './financial-evidence.ts';
import type { ProviderFailoverStep } from './provider.ts';

/**
* Finance Capability domain — the single source of truth for every piece of
Expand Down Expand Up @@ -34,6 +35,8 @@ export interface CapabilityProvenance {
provider: string;
/** Provider id of the ACTUAL answering adapter (fallback-aware). */
providerId?: string;
/** Human-readable name of the actual answering adapter, when available. */
providerName?: string;
/** Canonical instrument id when the capability ran against a resolved listing. */
instrumentId?: string;
/** Epoch ms at which the data was fetched. */
Expand All @@ -44,6 +47,8 @@ export interface CapabilityProvenance {
delayed?: boolean;
/** True when the data may be outdated relative to the market. */
stale: boolean;
/** Failed provider attempts before this result was produced. */
failoverTrail?: ProviderFailoverStep[];
}

export interface CapabilityResult<T> {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/financial-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface FinancialEvidenceEnvelope {
retrievedAt: number;
asOf?: number;
stale: boolean;
/** True when the provider reported a delayed value rather than live data. */
delayed?: boolean;
cacheHit: boolean;
fallback?: { from: string; to: string; reason?: string };
reconciliation?: { providers: string[]; method: string };
Expand Down
33 changes: 32 additions & 1 deletion packages/shared/src/agent/demo-market-data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { Holding, Kline, PortfolioAccount, PortfolioSnapshot, Quote } from '@finagent/core';
import type { MarketDataFetchers } from './market-data-service.ts';
import type { CapabilityFetchResult } from '../capabilities/fetchers.ts';

type QuoteResultFetcher = (symbol: string, signal?: AbortSignal) => Promise<CapabilityFetchResult<Quote>>;

/**
* Built-in sample data for the offline demo path (#31 E2E, README offline
Expand Down Expand Up @@ -57,6 +60,20 @@ function demoQuoteFor(symbol: string): Quote {
};
}

function demoQuoteResult(symbol: string): CapabilityFetchResult<Quote> {
const data = demoQuoteFor(symbol);
return {
data,
provenance: {
providerId: 'demo',
providerName: 'Built-in demo data',
fetchedAt: Date.now(),
marketTime: data.timestamp * 1000,
stale: false,
},
};
}

/** Deterministic daily close series ending at the demo quote's last price. */
function demoKlinesFor(symbol: string, limit?: number): Kline[] {
const count = Math.max(2, Math.min(limit ?? 30, 400));
Expand Down Expand Up @@ -122,7 +139,10 @@ function demoPortfolioSnapshot(): PortfolioSnapshot {
* demo dataset answers instead. Only the surfaces typed blocks demo are
* wrapped; everything else keeps failing honestly.
*/
export function withDemoDataFallback<F extends Partial<MarketDataFetchers>>(fetchers: F): F {
export function withDemoDataFallback<F extends Partial<MarketDataFetchers> & { getQuoteResult?: QuoteResultFetcher }>(
fetchers: F
): F {
const quoteResult = fetchers.getQuoteResult;
return {
...fetchers,
getQuote: async (symbol) => {
Expand All @@ -136,6 +156,17 @@ export function withDemoDataFallback<F extends Partial<MarketDataFetchers>>(fetc
}
return demoQuoteFor(symbol);
},
...(quoteResult
? {
getQuoteResult: async (symbol: string, signal?: AbortSignal) => {
try {
return await quoteResult(symbol, signal);
} catch {
return demoQuoteResult(symbol);
}
},
}
: {}),
getKline: async (options) => {
const real = fetchers.getKline;
if (real) {
Expand Down
77 changes: 75 additions & 2 deletions packages/shared/src/capabilities/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type CapabilityResult,
type CapabilityRiskLevel,
type FinanceCapability,
type ProviderProvenance,
} from '@finagent/core';
import { validateInput } from './validate.ts';

Expand All @@ -21,7 +22,11 @@ export interface CapabilityDefinition<TInput = unknown, TOutput = unknown> {
auth: CapabilityAuth;
toolName: string;
inputSchema: TSchema;
execute(input: TInput, ctx?: CapabilityExecutionContext): Promise<CapabilityResult<TOutput>>;
execute(
input: TInput,
ctx?: CapabilityExecutionContext,
reportProvider?: (provenance: ProviderProvenance) => void
): Promise<CapabilityResult<TOutput>>;
}

/**
Expand All @@ -45,7 +50,13 @@ export function defineCapability<TInput = unknown, TOutput = unknown>(
inputSchema: def.inputSchema,
async execute(input, ctx) {
const validated = validateInput<TInput>(def.inputSchema, input);
return attachInstrumentIdToProvenance(await def.execute(validated, ctx));
let providerProvenance: ProviderProvenance | undefined;
const result = await def.execute(validated, ctx, (provenance) => {
providerProvenance = cloneProviderProvenance(provenance);
});
return attachInstrumentIdToProvenance(
attachProviderProvenance(result, providerProvenance)
);
},
};
}
Expand All @@ -59,3 +70,65 @@ function attachInstrumentIdToProvenance<T>(result: CapabilityResult<T>): Capabil
provenance: { ...result.provenance, instrumentId },
};
}

function attachProviderProvenance<T>(
result: CapabilityResult<T>,
providerProvenance?: ProviderProvenance
): CapabilityResult<T> {
if (!providerProvenance) return result;

const failoverTrail = providerProvenance.failoverTrail?.map((step) => ({ ...step }));
const routingLineage = failoverTrail?.map((step) => ({
kind: 'fallback' as const,
description: `Provider routing skipped ${step.providerId} after ${step.attempts} attempt${step.attempts === 1 ? '' : 's'} (${step.code}; ${step.kind}).`,
})) ?? [];
const firstFailure = failoverTrail?.[0];
const fallback = !providerProvenance.stale
&& firstFailure
&& firstFailure.providerId !== providerProvenance.providerId
? {
from: firstFailure.providerId,
to: providerProvenance.providerId,
reason: failoverTrail.map((step) => step.code).join(' → '),
}
: undefined;
const evidence = routingLineage.length > 0 || fallback
? {
...result.evidence,
...(result.evidence?.fallback || !fallback ? {} : { fallback }),
...(routingLineage.length > 0
? { lineage: [...routingLineage, ...(result.evidence?.lineage ?? [])] }
: {}),
}
: result.evidence;

return {
...result,
provenance: {
...result.provenance,
provider: providerProvenance.providerId,
providerId: providerProvenance.providerId,
providerName: providerProvenance.providerName,
...(providerProvenance.instrumentId ? { instrumentId: providerProvenance.instrumentId } : {}),
fetchedAt: providerProvenance.fetchedAt,
...(providerProvenance.marketTime !== undefined
? { marketTime: providerProvenance.marketTime }
: {}),
...(providerProvenance.delayed !== undefined
? { delayed: providerProvenance.delayed }
: {}),
stale: providerProvenance.stale,
...(failoverTrail ? { failoverTrail } : {}),
},
...(evidence ? { evidence } : {}),
};
}

function cloneProviderProvenance(provenance: ProviderProvenance): ProviderProvenance {
return {
...provenance,
...(provenance.failoverTrail
? { failoverTrail: provenance.failoverTrail.map((step) => ({ ...step })) }
: {}),
};
}
15 changes: 15 additions & 0 deletions packages/shared/src/capabilities/fetchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
MarketTemperature,
NewsItem,
PortfolioSnapshot,
ProviderProvenance,
Quote,
StaticInfo,
TradeTick,
Expand Down Expand Up @@ -46,14 +47,28 @@ import {
type GetKlineOptions,
} from '@finagent/longbridge-tools';

/**
* Opt-in enriched fetch result for capability paths that need the router's
* actual-provider provenance in addition to the normalized payload.
*/
export interface CapabilityFetchResult<T> {
data: T;
provenance: ProviderProvenance;
}

/**
* Provider fetchers consumed by the capability manifests. Production uses the
* raw Longbridge fetchers; tests and the local backend can substitute a
* `MarketDataService` (which satisfies this shape structurally) to inject
* cached or stubbed data.
*
* Raw methods deliberately remain payload-only for renderer and legacy
* consumers. A capability can opt into a matching `*Result` method when it
* needs source provenance for a traceable product flow.
*/
export interface CapabilityFetchers {
getQuote: (symbol: string) => Promise<Quote>;
getQuoteResult?: (symbol: string, signal?: AbortSignal) => Promise<CapabilityFetchResult<Quote>>;
getKline: (options: GetKlineOptions) => Promise<Kline[]>;
getIntraday: (symbol: string) => Promise<IntradayData[]>;
getMarketStatus: () => Promise<MarketStatus[]>;
Expand Down
6 changes: 5 additions & 1 deletion packages/shared/src/capabilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export {
export { computeSkillReadiness } from './readiness.ts';
export { createCapabilityTools, type CapabilityTool } from './pi-tools.ts';
export { createCapabilityError, validateInput, normalizeSymbol } from './validate.ts';
export { defaultCapabilityFetchers, type CapabilityFetchers } from './fetchers.ts';
export {
defaultCapabilityFetchers,
type CapabilityFetchers,
type CapabilityFetchResult,
} from './fetchers.ts';
export { createPhaseOneCapabilities, phaseOneCapabilities } from './manifests/index.ts';

/** All twenty capabilities built from the default (real) Longbridge fetchers. */
Expand Down
16 changes: 11 additions & 5 deletions packages/shared/src/capabilities/manifests/market-quote.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Type } from '@sinclair/typebox';
import type { Quote } from '@finagent/core';
import type { FinanceCapability } from '@finagent/core';
import type {
FinanceCapability,
Quote,
} from '@finagent/core';
import { defineCapability } from '../define.ts';
import { normalizeSymbol } from '../validate.ts';
import type { CapabilityFetchers } from '../fetchers.ts';
Expand All @@ -24,15 +26,19 @@ export function createMarketQuoteCapability(
examples: ['AAPL.US', '0700.HK'],
}),
}),
async execute(input, ctx) {
async execute(input, ctx, reportProvider) {
const symbol = normalizeSymbol(input.symbol);
const quote = await fetchers.getQuote(symbol);
const fetched = fetchers.getQuoteResult
? await fetchers.getQuoteResult(symbol, ctx?.signal)
: undefined;
const quote = fetched?.data ?? await fetchers.getQuote(symbol);
if (fetched) reportProvider?.(fetched.provenance);
return {
data: quote,
provenance: {
provider: 'longbridge',
fetchedAt: (ctx?.now ?? Date.now)(),
marketTime: quote.timestamp,
marketTime: quote.timestamp * 1000,
stale: false,
},
summary: formatQuote(quote),
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/evidence/financial-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import {
FINANCIAL_EVIDENCE_SCHEMA_VERSION,
FINANCIAL_NORMALIZATION_VERSION,
readInstrumentId,
} from '@finagent/core';

const SECRET_KEY = /(authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|password|cookie|secret|credential)/i;
Expand Down Expand Up @@ -51,7 +52,7 @@ export function buildFinancialEvidence(input: BuildFinancialEvidenceInput): Fina
const provider = stringValue(provenance.providerId) ?? stringValue(provenance.provider) ?? 'unknown';
const retrievedAt = numberValue(provenance.fetchedAt) ?? toolCall.completedAt ?? toolCall.startedAt;
const asOf = numberValue(provenance.marketTime) ?? inferAsOf(result.data);
const instrumentId = canonicalInstrument(toolCall.args.symbol ?? inferSymbol(result.data));
const instrumentId = readInstrumentId(provenance) ?? canonicalInstrument(toolCall.args.symbol ?? inferSymbol(result.data));
const values = collectValues(result.data, result.evidence);
const snapshot = redact(result.data);
const resultHash = hashJson(snapshot);
Expand Down Expand Up @@ -82,6 +83,7 @@ export function buildFinancialEvidence(input: BuildFinancialEvidenceInput): Fina
retrievedAt,
...(asOf !== undefined ? { asOf } : {}),
stale: provenance.stale === true,
...(provenance.delayed === true ? { delayed: true } : {}),
cacheHit: result.evidence?.cacheHit === true,
...(result.evidence?.fallback ? { fallback: result.evidence.fallback } : {}),
...(result.evidence?.reconciliation ? { reconciliation: result.evidence.reconciliation } : {}),
Expand Down
Loading