feat: add @tag model override support and remove Qdrant dependencies

- Add model-tags parser for @Tag syntax in chat messages
- Support Anthropic models (Sonnet, Haiku, Opus) via @tag
- Remove Qdrant vector database from infrastructure and configs
- Simplify license model config to use null fallbacks
- Add greeting stream after model switch via @tag
- Fix protobuf field names to camelCase for v7 compatibility
- Add 429 rate limit retry logic with exponential backoff
- Remove RAG references from agent harness documentation
This commit is contained in:
2026-04-27 20:55:18 -04:00
parent 6f937f9e5e
commit d41fcd0499
50 changed files with 956 additions and 798 deletions

View File

@@ -421,15 +421,79 @@ export class CCXTFetcher {
const amount = Math.round(trade.amount * sizeMult);
const quoteAmount = Math.round((trade.price * trade.amount) * priceMult);
// protobufjs v7 uses camelCase field names internally — must use camelCase here
return {
trade_id: trade.id || `${trade.timestamp}`,
tradeId: trade.id || `${trade.timestamp}`,
ticker,
timestamp: (trade.timestamp * 1_000_000).toString(), // Convert ms to nanoseconds
price: price.toString(),
amount: amount.toString(),
quote_amount: quoteAmount.toString(),
taker_buy: trade.side === 'buy',
sequence: trade.order ? trade.order.toString() : undefined
timestamp: (trade.timestamp * 1_000_000).toString(), // Convert ms to nanoseconds
price: price.toString(),
amount: amount.toString(),
quoteAmount: quoteAmount.toString(),
takerBuy: trade.side === 'buy',
sequence: trade.order ? trade.order.toString() : undefined
};
}
/**
* Fetch 1-minute bars covering the current open window for each configured period,
* rolling them up into a single aggregate per period for Flink accumulator seeding.
*
* Returns one seed object per period (or null for periods that just started with no
* completed 1m bars yet). Throws on exchange errors — caller handles retries.
*
* @param {string} ticker
* @param {number[]} periodSeconds - configured periods (e.g. [60, 300, 900, 3600, 14400, 86400])
* @returns {Promise<Array<{periodSeconds, open, high, low, close, volume, windowStartMs}|null>>}
*/
async fetchSeedCandles(ticker, periodSeconds) {
const nowMs = Date.now();
const maxPeriod = Math.max(...periodSeconds);
const longestWindowStart = Math.floor(nowMs / (maxPeriod * 1000)) * (maxPeriod * 1000);
// fetchHistoricalOHLC expects nanoseconds as strings
const startNs = (longestWindowStart * 1_000_000).toString();
const endNs = (nowMs * 1_000_000).toString();
const bars1m = await this.fetchHistoricalOHLC(ticker, startNs, endNs, 60, null);
return periodSeconds.map(period => {
const windowStart = Math.floor(nowMs / (period * 1000)) * (period * 1000);
const relevant = bars1m.filter(b => {
const tsMs = parseInt(b.timestamp) / 1_000_000;
return tsMs >= windowStart && tsMs < nowMs;
});
if (relevant.length === 0) return null;
const open = parseInt(relevant[0].open);
const high = Math.max(...relevant.map(b => parseInt(b.high)));
const low = Math.min(...relevant.map(b => parseInt(b.low)));
const close = parseInt(relevant[relevant.length - 1].close);
const volume = relevant.reduce((sum, b) => sum + parseInt(b.volume), 0);
return { periodSeconds: period, open, high, low, close, volume, windowStartMs: windowStart };
});
}
/**
* Convert a seed candle aggregate into a Tick-shaped object for Kafka.
* price = open (scaled int), amount = volume (scaled int); seed_* fields carry H/L/C/period.
*/
convertSeedToTick(seed, ticker) {
// protobufjs v7 uses camelCase field names internally — must use camelCase here
return {
tradeId: `seed-${ticker}-${seed.periodSeconds}-${seed.windowStartMs}`,
ticker,
timestamp: (seed.windowStartMs * 1_000_000).toString(),
price: seed.open,
amount: seed.volume,
quoteAmount: 0,
takerBuy: false,
isSeed: true,
seedHigh: seed.high,
seedLow: seed.low,
seedClose: seed.close,
seedWindowStartMs: seed.windowStartMs,
seedPeriodSeconds: seed.periodSeconds
};
}

View File

@@ -332,7 +332,9 @@ class IngestorWorker {
this.zmqClient.sendReject(jobId, 'Slot capacity exceeded').catch(() => {});
return;
}
this.handleRealtimeRequest(request);
this.handleRealtimeRequest(request).catch(err => {
this.logger.error({ jobId, requestId, error: err.message }, 'Unexpected error in realtime handler');
});
} else if (isTickerSnapshot) {
if (!this.pool.consumeSlot(jobId, exchange, 'HISTORICAL')) {
this.zmqClient.sendReject(jobId, 'Slot capacity exceeded').catch(() => {});
@@ -431,11 +433,40 @@ class IngestorWorker {
/**
* Start realtime tick polling for a job dispatched by Flink.
* Fetches seed candles first so Flink initializes the open-candle accumulator correctly.
*/
handleRealtimeRequest(request) {
async handleRealtimeRequest(request) {
const { jobId, requestId, ticker } = request;
this.logger.info({ jobId, requestId, ticker }, 'Processing realtime subscription request');
const periods = [60, 300, 900, 3600, 14400, 86400];
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 5000;
let seeds = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
seeds = await this.ccxtFetcher.fetchSeedCandles(ticker, periods);
break;
} catch (err) {
this.logger.warn({ jobId, ticker, attempt, error: err.message }, 'Seed candle fetch failed');
if (attempt < MAX_RETRIES) await new Promise(r => setTimeout(r, RETRY_DELAY_MS * attempt));
}
}
if (seeds !== null) {
const seedTicks = seeds
.filter(s => s !== null)
.map(s => this.ccxtFetcher.convertSeedToTick(s, ticker));
if (seedTicks.length > 0) {
await this.kafkaProducer.writeTicks(this.config.kafka_tick_topic, seedTicks);
this.logger.info({ jobId, ticker, count: seedTicks.length }, 'Wrote seed ticks');
}
} else {
// All retries exhausted — open bars suppressed for current partial window until next candle boundary
this.logger.error({ jobId, ticker }, 'All seed retries failed — open bars suppressed until next candle');
}
this.activeRealtime.add(jobId);
this.realtimePoller.startSubscription(jobId, requestId, ticker, this.config.kafka_tick_topic);
}