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
10 changes: 3 additions & 7 deletions src/connectors/telegram/telegram-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class TelegramPlugin implements Plugin {
private agentSdkConfig: AgentSdkConfig
private bot: Bot | null = null
private connectorCenter: ConnectorCenter | null = null
private ctx: EngineContext | null = null
private merger: MediaGroupMerger | null = null
private unregisterConnector?: () => void
private unsubscribeNotifications?: () => void
Expand All @@ -50,6 +51,7 @@ export class TelegramPlugin implements Plugin {
}

async start(engineCtx: EngineContext) {
this.ctx = engineCtx
this.connectorCenter = engineCtx.connectorCenter
this.webPort = engineCtx.config.connectors.web.port

Expand Down Expand Up @@ -355,13 +357,7 @@ export class TelegramPlugin implements Plugin {
const session = await this.getSession(userId)
await this.sendReply(chatId, '> Compacting session...')

const result = await forceCompact(
session,
async (summarizePrompt) => {
const r = await askAgentSdk(summarizePrompt, { ...this.agentSdkConfig, maxTurns: 1 })
return r.text
},
)
const result = await this.ctx!.agentCenter.forceCompact(session)

if (!result) {
await this.sendReply(chatId, 'Session is empty, nothing to compact.')
Expand Down
11 changes: 10 additions & 1 deletion src/core/agent-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { resolveProfile, resolveCredential } from './config.js'
import { profileToCredential } from './credential-inference.js'
import type { ISessionStore, ContentBlock } from './session.js'
import type { CompactionConfig } from './compaction.js'
import { compactIfNeeded } from './compaction.js'
import { compactIfNeeded, forceCompact } from './compaction.js'
import type { MediaAttachment } from './types.js'
import { extractMediaFromToolResultContent } from './media.js'
import { persistMedia } from './media-store.js'
Expand Down Expand Up @@ -91,6 +91,15 @@ export class AgentCenter {
return new StreamableResult(this._generate(prompt, session, opts))
}

/** Force a full compaction (summarization) of the session. */
async forceCompact(session: ISessionStore, opts?: AskOptions): Promise<{ preTokens: number } | null> {
const { provider } = await this.router.resolve(opts?.profileSlug)
return forceCompact(session, async (summarizePrompt) => {
const result = await provider.ask(summarizePrompt)
return result.text
})
}

// ==================== Pipeline ====================

private async *_generate(
Expand Down
52 changes: 43 additions & 9 deletions src/domain/trading/UnifiedTradingAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,19 +331,34 @@ export class UnifiedTradingAccount {

// ==================== aliceId management ====================

/**
* Resolve a Contract to its full broker-native form.
* If contract.aliceId is present but native fields (conId, localSymbol) are
* missing, it parses the nativeKey from aliceId and asks the broker to resolve it.
*/
resolveContract(contract: Contract): Contract {
if (contract.aliceId && !contract.conId && !contract.localSymbol) {
const parsed = UnifiedTradingAccount.parseAliceId(contract.aliceId)
if (parsed && parsed.utaId === this.id) {
const resolved = this.broker.resolveNativeKey(parsed.nativeKey)
// Copy resolved fields into original object to preserve reference
contract.conId = resolved.conId
contract.localSymbol = resolved.localSymbol
contract.symbol = contract.symbol || resolved.symbol
contract.secType = contract.secType || resolved.secType
contract.currency = contract.currency || resolved.currency
contract.exchange = contract.exchange || resolved.exchange
}
}
return contract
}

/** Construct aliceId: "{utaId}|{nativeKey}" using broker's native identity. */
private stampAliceId(contract: Contract): void {
const nativeKey = this.broker.getNativeKey(contract)
contract.aliceId = `${this.id}|${nativeKey}`
}

/** Parse aliceId → { utaId, nativeKey }, or null if invalid. */
static parseAliceId(aliceId: string): { utaId: string; nativeKey: string } | null {
const sep = aliceId.indexOf('|')
if (sep === -1) return null
return { utaId: aliceId.slice(0, sep), nativeKey: aliceId.slice(sep + 1) }
}

/**
* Reverse of `stampAliceId`: parse an aliceId, verify it belongs to this
* UTA, and rebuild the full Contract via the broker's native-key resolver.
Expand All @@ -369,11 +384,23 @@ export class UnifiedTradingAccount {
return contract
}

/** Parse aliceId → { utaId, nativeKey }, or null if invalid. */
static parseAliceId(aliceId: string): { utaId: string; nativeKey: string } | null {
const sep = aliceId.indexOf('|')
if (sep === -1) return null
return { utaId: aliceId.slice(0, sep), nativeKey: aliceId.slice(sep + 1) }
}

// ==================== Stage operations ====================

stagePlaceOrder(params: StagePlaceOrderParams): AddResult {
// Resolve aliceId → full contract via broker (fills secType, exchange, currency, conId, etc.)
const contract = this.contractFromAliceId(params.aliceId)
const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId)
if (!parsed) {
throw new Error(`Invalid aliceId "${params.aliceId}". Use searchContracts to get a valid contract identifier (expected format: "accountId|nativeKey").`)
}
const contract = this.broker.resolveNativeKey(parsed.nativeKey)
contract.aliceId = params.aliceId
if (params.symbol) contract.symbol = params.symbol

const order = new Order()
Expand Down Expand Up @@ -415,7 +442,12 @@ export class UnifiedTradingAccount {
}

stageClosePosition(params: StageClosePositionParams): AddResult {
const contract = this.contractFromAliceId(params.aliceId)
const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId)
if (!parsed) {
throw new Error(`Invalid aliceId "${params.aliceId}". Use searchContracts to get a valid contract identifier (expected format: "accountId|nativeKey").`)
}
const contract = this.broker.resolveNativeKey(parsed.nativeKey)
contract.aliceId = params.aliceId
if (params.symbol) contract.symbol = params.symbol

return this.git.add({
Expand Down Expand Up @@ -620,6 +652,7 @@ export class UnifiedTradingAccount {
}

async getQuote(contract: Contract): Promise<Quote> {
this.resolveContract(contract)
const quote = await this._callBroker(() => this.broker.getQuote(contract))
this.stampAliceId(quote.contract)
return quote
Expand Down Expand Up @@ -648,6 +681,7 @@ export class UnifiedTradingAccount {
}

async getContractDetails(query: Contract): Promise<ContractDetails | null> {
this.resolveContract(query)
const details = await this._callBroker(() => this.broker.getContractDetails(query))
if (details) this.stampAliceId(details.contract)
return details
Expand Down
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ async function main() {
// ==================== Cron ====================

const cronEngine = createCronEngine({ registry: listenerRegistry })
await cronEngine.start()
console.log('cron: engine started')

// ==================== News Collector Store ====================

Expand Down Expand Up @@ -303,9 +305,7 @@ async function main() {
// ==================== Activate Listeners + Start Cron Engine ====================

await listenerRegistry.start()
await cronEngine.start()
console.log(`listener-registry: started (${listenerRegistry.list().length} listeners)`)
console.log('cron: engine started')

// ==================== News Collector ====================

Expand Down
13 changes: 12 additions & 1 deletion src/task/heartbeat/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export interface HeartbeatOpts {
export interface Heartbeat {
start(): Promise<void>
stop(): void
/** Update heartbeat config (interval, prompt, etc.) and sync with pump. */
updateConfig(newConfig: HeartbeatConfig): Promise<void>
/** Hot-toggle heartbeat on/off (persists to config + updates pump). */
setEnabled(enabled: boolean): Promise<void>
/** Current enabled state. */
Expand All @@ -99,7 +101,8 @@ export interface Heartbeat {
// ==================== Factory ====================

export function createHeartbeat(opts: HeartbeatOpts): Heartbeat {
const { config, agentWorkListener, registry } = opts
let { config } = opts
const { agentWorkListener, registry } = opts
const session = opts.session ?? new SessionStore('heartbeat')
const now = opts.now ?? Date.now

Expand Down Expand Up @@ -191,6 +194,14 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat {
started = false
},

async updateConfig(newConfig: HeartbeatConfig) {
config = { ...newConfig }
enabled = config.enabled
if (pump) {
pump.setEnabled(enabled)
}
},

async setEnabled(newEnabled: boolean) {
enabled = newEnabled
pump?.setEnabled(newEnabled)
Expand Down
136 changes: 35 additions & 101 deletions src/tool/trading.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,119 +252,53 @@ describe('createTradingTools — getOrders summarization', () => {
})
})

// ==================== getQuote (aliceId resolution) ====================
// ==================== getPortfolio ====================

describe('createTradingTools — getQuote', () => {
it('resolves aliceId via UTA so broker sees a contract with native fields', async () => {
const broker = new MockBroker({ id: 'mock-paper' })
const spy = vi.spyOn(broker, 'getQuote')
const tools = createTradingTools(makeManager(broker))

const result = await (tools.getQuote.execute as Function)({ aliceId: 'mock-paper|AAPL' })

expect(spy).toHaveBeenCalledTimes(1)
const [passedContract] = spy.mock.calls[0]
// Without contractFromAliceId, this would be empty and broker resolution
// would fail. With the fix, MockBroker.resolveNativeKey populates symbol.
expect(passedContract.symbol || passedContract.localSymbol).toBeTruthy()
expect(passedContract.aliceId).toBe('mock-paper|AAPL')
expect(result.source).toBe('mock-paper')
})
describe('createTradingTools — getPortfolio', () => {
it('returns positions with aliceId and calculated percentages', async () => {
const broker = new MockBroker({ id: 'mock-paper', cash: 100000 })
broker.setMarkPrice('AAPL', 150)
broker.setMarkPrice('TSLA', 200)

it('returns error on malformed aliceId', async () => {
const broker = new MockBroker({ id: 'mock-paper' })
const tools = createTradingTools(makeManager(broker))
const result = await (tools.getQuote.execute as Function)({ aliceId: 'no-separator-here' })
expect(result.error).toMatch(/Invalid aliceId/)
})
// AAPL: 100 shares @ 150 = 15,000
broker.externalDeposit({ nativeKey: 'AAPL', quantity: 100 })
// TSLA: 50 shares @ 200 = 10,000
broker.externalDeposit({ nativeKey: 'TSLA', quantity: 50 })

it('routes to the UTA encoded in the aliceId without an explicit source', async () => {
const a1 = new MockBroker({ id: 'alpaca-paper' })
const a2 = new MockBroker({ id: 'bybit-main' })
const spy1 = vi.spyOn(a1, 'getQuote')
const spy2 = vi.spyOn(a2, 'getQuote')
const tools = createTradingTools(makeManager(a1, a2))
const mgr = makeManager(broker)
const tools = createTradingTools(mgr)

await (tools.getQuote.execute as Function)({ aliceId: 'bybit-main|BTC' })
const result = await (tools.getPortfolio.execute as Function)({ source: 'mock-paper' })

expect(spy2).toHaveBeenCalledTimes(1)
expect(spy1).not.toHaveBeenCalled()
})
})
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(2)

// ==================== getContractDetails (aliceId resolution) ====================
const aapl = result.find((p: any) => p.symbol === 'AAPL')
expect(aapl).toBeDefined()
expect(aapl.aliceId).toBe('mock-paper|AAPL')
expect(aapl.marketValue).toBe('15000')

describe('createTradingTools — getContractDetails', () => {
it('expands aliceId via UTA before calling broker.getContractDetails', async () => {
const broker = new MockBroker({ id: 'mock-paper' })
const spy = vi.spyOn(broker, 'getContractDetails')
const tools = createTradingTools(makeManager(broker))

await (tools.getContractDetails.execute as Function)({
source: 'mock-paper',
aliceId: 'mock-paper|AAPL',
})

expect(spy).toHaveBeenCalledTimes(1)
const [passedQuery] = spy.mock.calls[0]
expect(passedQuery.symbol || passedQuery.localSymbol).toBeTruthy()
expect(passedQuery.aliceId).toBe('mock-paper|AAPL')
const tsla = result.find((p: any) => p.symbol === 'TSLA')
expect(tsla).toBeDefined()
expect(tsla.aliceId).toBe('mock-paper|TSLA')
expect(tsla.marketValue).toBe('10000')
})

it('returns error on cross-UTA aliceId mismatch', async () => {
it('filters by symbol', async () => {
const broker = new MockBroker({ id: 'mock-paper' })
const tools = createTradingTools(makeManager(broker))
const result = await (tools.getContractDetails.execute as Function)({
source: 'mock-paper',
aliceId: 'other-account|AAPL',
})
expect(result.error).toMatch(/belongs to UTA "other-account"/)
})
})

// ==================== placeOrder schema (AI ergonomics) ====================

describe('placeOrder inputSchema', () => {
// LLMs frequently emit "" for fields they don't intend to set rather than
// omitting the key. Without empty-string tolerance, every optional numeric
// field rejects with "must be a positive numeric string" and the whole MKT
// call fails at the schema gate (the cashQty/lmtPrice/auxPrice cluster bug
// reported 2026-05-12).
it('treats empty-string optional numeric fields as omitted', () => {
const broker = new MockBroker({ id: 'mock-paper' })
const tools = createTradingTools(makeManager(broker))

const result = (tools.placeOrder.inputSchema as any).safeParse({
source: 'mock-paper',
aliceId: 'mock-paper|AAPL',
action: 'BUY',
orderType: 'MKT',
totalQuantity: '0.01',
cashQty: '',
lmtPrice: '',
auxPrice: '',
trailStopPrice: '',
trailingPercent: '',
})

expect(result.success).toBe(true)
expect(result.data.cashQty).toBeUndefined()
expect(result.data.lmtPrice).toBeUndefined()
expect(result.data.totalQuantity).toBe('0.01')
})
broker.setMarkPrice('AAPL', 150)
broker.setMarkPrice('TSLA', 200)
broker.externalDeposit({ nativeKey: 'AAPL', quantity: 100 })
broker.externalDeposit({ nativeKey: 'TSLA', quantity: 50 })

it('still rejects non-empty invalid numerics', () => {
const broker = new MockBroker({ id: 'mock-paper' })
const tools = createTradingTools(makeManager(broker))
const mgr = makeManager(broker)
const tools = createTradingTools(mgr)

const result = (tools.placeOrder.inputSchema as any).safeParse({
source: 'mock-paper',
aliceId: 'mock-paper|AAPL',
action: 'BUY',
orderType: 'MKT',
totalQuantity: '0',
})
const result = await (tools.getPortfolio.execute as Function)({ source: 'mock-paper', symbol: 'AAPL' })

expect(result.success).toBe(false)
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(1)
expect(result[0].symbol).toBe('AAPL')
expect(result[0].aliceId).toBe('mock-paper|AAPL')
})
})
Loading