diff --git a/apps/desktop/dev-app-update.yml b/apps/desktop/dev-app-update.yml new file mode 100644 index 00000000..95ca79f9 --- /dev/null +++ b/apps/desktop/dev-app-update.yml @@ -0,0 +1,3 @@ +provider: github +owner: multica-ai +repo: multica diff --git a/apps/desktop/electron-builder.json5 b/apps/desktop/electron-builder.json5 index b30a5ab7..0a7de5ff 100644 --- a/apps/desktop/electron-builder.json5 +++ b/apps/desktop/electron-builder.json5 @@ -63,5 +63,10 @@ "category": "Utility", "artifactName": "${productName}-Linux-${version}.${ext}" }, - "npmRebuild": false + "npmRebuild": false, + "publish": { + "provider": "github", + "owner": "multica-ai", + "repo": "multica" + } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9730e107..41d9da13 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -19,6 +19,7 @@ "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", +"electron-updater": "^6.7.3", "lucide-react": "^0.563.0", "qrcode.react": "^4.2.0", "react": "catalog:", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 47fba83b..a4428d31 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -49,6 +49,7 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation } from './ipc/index.js' import { appStateManager } from '@multica/core' +import { createUpdater, AutoUpdater } from './updater/index.js' // CJS output will have __dirname natively, but TypeScript source needs this for type checking const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -68,6 +69,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, const forceOnboarding = process.argv.includes('--force-onboarding') let win: BrowserWindow | null +let updater: AutoUpdater function createWindow() { win = new BrowserWindow({ @@ -138,4 +140,30 @@ app.whenReady().then(async () => { if (win) { setupDeviceConfirmation(win) } + + // Initialize auto-updater + const forceDevUpdate = process.env.FORCE_DEV_UPDATE === 'true' + updater = createUpdater(forceDevUpdate) + updater.setMainWindow(() => win) + + // Auto-check for updates in production (or when forced in dev) + const isDev = !!VITE_DEV_SERVER_URL + if (!isDev || forceDevUpdate) { + win?.once('ready-to-show', () => { + updater.checkForUpdates() + }) + } + + // Update IPC handlers + ipcMain.handle('update:check', async () => { + await updater.checkForUpdates() + }) + + ipcMain.handle('update:download', async () => { + await updater.downloadUpdate() + }) + + ipcMain.handle('update:install', () => { + updater.quitAndInstall() + }) }) diff --git a/apps/desktop/src/main/updater/index.ts b/apps/desktop/src/main/updater/index.ts new file mode 100644 index 00000000..572b1d61 --- /dev/null +++ b/apps/desktop/src/main/updater/index.ts @@ -0,0 +1,124 @@ +/** + * Auto-updater module using electron-updater + * Checks for updates from GitHub releases and handles download/install + */ +import pkg from 'electron-updater' +import type { UpdateInfo, ProgressInfo } from 'electron-updater' + +const { autoUpdater } = pkg +import { BrowserWindow } from 'electron' + +export interface UpdateStatus { + status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' + info?: UpdateInfo + progress?: ProgressInfo + error?: string +} + +export class AutoUpdater { + private mainWindow: (() => BrowserWindow | null) | null = null + + constructor(forceDevUpdateConfig = false) { + // Configure auto-updater + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = true + + // Enable update checking in dev mode for testing + if (forceDevUpdateConfig) { + autoUpdater.forceDevUpdateConfig = true + console.log('[AutoUpdater] Force dev update config enabled') + } + + // Enable logging + autoUpdater.logger = { + info: (msg) => console.log('[AutoUpdater]', msg), + warn: (msg) => console.warn('[AutoUpdater]', msg), + error: (msg) => console.error('[AutoUpdater]', msg), + debug: (msg) => console.log('[AutoUpdater:debug]', msg) + } + + // Set up event handlers + autoUpdater.on('checking-for-update', () => { + this.sendStatus({ status: 'checking' }) + }) + + autoUpdater.on('update-available', (info: UpdateInfo) => { + this.sendStatus({ status: 'available', info }) + }) + + autoUpdater.on('update-not-available', (info: UpdateInfo) => { + this.sendStatus({ status: 'not-available', info }) + }) + + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + this.sendStatus({ status: 'downloading', progress }) + }) + + autoUpdater.on('update-downloaded', (info: UpdateInfo) => { + this.sendStatus({ status: 'downloaded', info }) + }) + + autoUpdater.on('error', (err: Error) => { + this.sendStatus({ status: 'error', error: err.message }) + }) + } + + /** + * Set the main window reference for sending IPC messages + */ + setMainWindow(getWindow: () => BrowserWindow | null): void { + this.mainWindow = getWindow + } + + /** + * Check for updates + */ + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates() + } catch (err) { + console.error('[AutoUpdater] Check for updates failed:', err) + this.sendStatus({ + status: 'error', + error: err instanceof Error ? err.message : 'Unknown error' + }) + } + } + + /** + * Download the available update + */ + async downloadUpdate(): Promise { + try { + await autoUpdater.downloadUpdate() + } catch (err) { + console.error('[AutoUpdater] Download update failed:', err) + this.sendStatus({ + status: 'error', + error: err instanceof Error ? err.message : 'Download failed' + }) + } + } + + /** + * Quit and install the downloaded update + */ + quitAndInstall(): void { + autoUpdater.quitAndInstall() + } + + /** + * Send update status to renderer + */ + private sendStatus(status: UpdateStatus): void { + const window = this.mainWindow?.() + if (window && !window.isDestroyed()) { + window.webContents.send('update:status', status) + } + } +} + +// Factory function to create updater with options +export function createUpdater(forceDevUpdateConfig = false): AutoUpdater { + return new AutoUpdater(forceDevUpdateConfig) +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 97d120bc..d31d42bc 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -245,6 +245,26 @@ const electronAPI = { wake: (reason?: string) => ipcRenderer.invoke('heartbeat:wake', reason), }, + // Auto-update + update: { + /** Check for updates */ + check: () => ipcRenderer.invoke('update:check'), + /** Download the available update */ + download: () => ipcRenderer.invoke('update:download'), + /** Quit and install the downloaded update */ + install: () => ipcRenderer.invoke('update:install'), + /** Listen for update status changes (returns unsubscribe function) */ + onStatus: (callback: (status: { status: string; info?: unknown; progress?: unknown; error?: string }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: Parameters[0]): void => { + callback(status) + } + ipcRenderer.on('update:status', listener) + return (): void => { + ipcRenderer.removeListener('update:status', listener) + } + }, + }, + // Local chat (direct IPC, no Gateway required) localChat: { /** Subscribe to agent events for local direct chat */ diff --git a/apps/desktop/src/renderer/src/components/update-notification.tsx b/apps/desktop/src/renderer/src/components/update-notification.tsx new file mode 100644 index 00000000..4eb85fdf --- /dev/null +++ b/apps/desktop/src/renderer/src/components/update-notification.tsx @@ -0,0 +1,144 @@ +/** + * Update notification component + * Shows when a new version is available and allows user to download/install + */ +import { useState, useEffect } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Download04Icon, + Loading03Icon, + CheckmarkCircle02Icon, + AlertCircleIcon, + Cancel01Icon, +} from '@hugeicons/core-free-icons' +import { Button } from '@multica/ui/components/ui/button' + +interface UpdateInfo { + version: string + releaseDate?: string + releaseNotes?: string | null +} + +interface UpdateProgress { + percent: number + bytesPerSecond: number + total: number + transferred: number +} + +interface UpdateStatus { + status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' + info?: UpdateInfo + progress?: UpdateProgress + error?: string +} + +export function UpdateNotification(): React.JSX.Element | null { + const [updateStatus, setUpdateStatus] = useState(null) + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + const unsubscribe = window.electronAPI.update.onStatus((status: UpdateStatus) => { + setUpdateStatus(status) + // Reset dismissed state when a new update becomes available + if (status.status === 'available') { + setDismissed(false) + } + }) + + return () => unsubscribe() + }, []) + + const handleDownload = async (): Promise => { + await window.electronAPI.update.download() + } + + const handleInstall = (): void => { + window.electronAPI.update.install() + } + + const handleDismiss = (): void => { + setDismissed(true) + } + + // Don't show if dismissed or no relevant status + if (dismissed) return null + if (!updateStatus) return null + if (updateStatus.status === 'checking' || updateStatus.status === 'not-available') return null + + const version = updateStatus.info?.version + const isError = updateStatus.status === 'error' + + return ( +
+
+ {/* Icon */} +
+ {isError ? ( + + ) : updateStatus.status === 'downloaded' ? ( + + ) : updateStatus.status === 'downloading' ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+ + {isError + ? 'Update failed' + : updateStatus.status === 'downloaded' + ? 'Update ready' + : updateStatus.status === 'downloading' + ? 'Downloading update...' + : 'Update available'} + + + {isError + ? 'Please download manually from GitHub' + : updateStatus.status === 'downloading' && updateStatus.progress + ? `${Math.round(updateStatus.progress.percent)}%` + : version + ? `Version ${version}` + : 'New version available'} + +
+ + {/* Actions */} +
+ {updateStatus.status === 'available' && ( + + )} + {updateStatus.status === 'downloaded' && ( + + )} + {isError && ( + + )} + {updateStatus.status !== 'downloading' && ( + + )} +
+
+
+ ) +} diff --git a/packages/core/src/agent/async-agent.test.ts b/packages/core/src/agent/async-agent.test.ts index 26e7cc9d..e9700d03 100644 --- a/packages/core/src/agent/async-agent.test.ts +++ b/packages/core/src/agent/async-agent.test.ts @@ -272,6 +272,18 @@ describe("AsyncAgent internal flow", () => { agent.close(); }); + it("does not persist assistant summary when result text is a NO_REPLY variant", async () => { + runInternalMock.mockResolvedValueOnce({ text: "NO_REPLY.", thinking: undefined, error: undefined }); + const agent = new AsyncAgent(); + + agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true }); + await agent.waitForIdle(); + + expect(persistAssistantSummaryMock).not.toHaveBeenCalled(); + + agent.close(); + }); + it("does not persist assistant summary when result text is empty", async () => { runInternalMock.mockResolvedValueOnce({ text: " ", thinking: undefined, error: undefined }); const agent = new AsyncAgent(); diff --git a/packages/core/src/agent/async-agent.ts b/packages/core/src/agent/async-agent.ts index 30e1f94d..e7ff1957 100644 --- a/packages/core/src/agent/async-agent.ts +++ b/packages/core/src/agent/async-agent.ts @@ -5,6 +5,8 @@ import { Channel } from "./channel.js"; import type { AgentOptions, Message } from "./types.js"; import type { MulticaEvent } from "./events.js"; import { injectMessageTimestamp } from "./message-timestamp.js"; +import { isSilentReplyText } from "./tokens.js"; +import { isHeartbeatAckEvent } from "../hub/heartbeat-filter.js"; const devNull = { write: () => true } as unknown as NodeJS.WritableStream; @@ -31,6 +33,7 @@ export class AsyncAgent { private pendingWrites = 0; private closeCallbacks: Array<() => void> = []; private forwardInternalAssistant = false; + private _lastRunError: string | undefined; readonly sessionId: string; constructor(options?: AgentOptions) { @@ -43,10 +46,10 @@ export class AsyncAgent { // Forward raw AgentEvent and MulticaEvent into the channel. // Suppress forwarding during internal runs to avoid leaking // orchestration messages to the frontend/real-time stream. - this.agent.subscribeAll((event: AgentEvent | MulticaEvent) => { - if (!this.shouldForwardEvent(event)) return; - this.channel.send(event); - }); + // Also suppresses pure heartbeat ACK messages (e.g. "HEARTBEAT_OK"). + this.agent.subscribeAll( + this.createFilteredHandler((event) => this.channel.send(event)), + ); } get closed(): boolean { @@ -64,13 +67,19 @@ export class AsyncAgent { this.queue = this.queue .then(async () => { - if (this._closed) return; + if (this._closed) { + console.log(`[AsyncAgent:${this.sessionId.slice(0, 8)}] write() skipped — agent closed`); + return; + } + console.log(`[AsyncAgent:${this.sessionId.slice(0, 8)}] run() starting for message: ${content.slice(0, 80)}`); const result = await this.agent.run(message, { displayPrompt: content }); + console.log(`[AsyncAgent:${this.sessionId.slice(0, 8)}] run() completed, error=${result.error ?? "none"}`); // Flush pending session writes so waitForIdle() callers // can safely read session data from disk. await this.agent.flushSession(); // Normal text is delivered via message_end event; only handle errors here if (result.error) { + this._lastRunError = result.error; console.error(`[AsyncAgent] Agent run error: ${result.error}`); this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` }); // Only emit agent_error for HTTP 401 from the LLM provider so the @@ -83,6 +92,7 @@ export class AsyncAgent { }) .catch((err) => { const message = err instanceof Error ? err.message : String(err); + this._lastRunError = message; console.error(`[AsyncAgent] Agent run exception: ${message}`); this.channel.send({ id: uuidv7(), content: `[error] ${message}` }); // Only emit agent_error for HTTP 401 from the LLM provider so the @@ -120,8 +130,11 @@ export class AsyncAgent { // Internal run errors are for diagnostics only; do not leak to user stream. console.error(`[AsyncAgent] Internal run error: ${result.error}`); } + // Stop forwarding BEFORE persist to avoid double-emitting the same + // assistant message (once from runInternal streaming, once from appendMessage). + this.forwardInternalAssistant = prevForward; // Persist the LLM summary so it remains in parent context for future turns - if (persistResponse && result.text?.trim() && result.text.trim() !== "NO_REPLY") { + if (persistResponse && result.text?.trim() && !isSilentReplyText(result.text)) { this.agent.persistAssistantSummary(result.text.trim()); await this.agent.flushSession(); } @@ -136,6 +149,33 @@ export class AsyncAgent { }); } + /** + * Run an internal prompt and return the result. + * Serialized through the write queue. Messages are persisted with + * `internal: true` and rolled back from in-memory state, so they + * won't appear in UI history or `getMessages()`. + */ + runInternalForResult(content: string): Promise<{ text: string; error?: string }> { + if (this._closed) return Promise.resolve({ text: "", error: "Agent is closed" }); + return new Promise((resolve) => { + this.queue = this.queue + .then(async () => { + if (this._closed) { + resolve({ text: "", error: "Agent is closed" }); + return; + } + const result = await this.agent.runInternal(content); + await this.agent.flushSession(); + resolve({ text: result.text, error: result.error }); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`[AsyncAgent] runInternalForResult failed: ${message}`); + resolve({ text: "", error: message }); + }); + }); + } + /** Continuously read channel stream (AgentEvent + error Messages) */ read(): AsyncIterable { return this.channel; @@ -148,11 +188,12 @@ export class AsyncAgent { */ subscribe(callback: (event: AgentEvent | MulticaEvent) => void): () => void { console.log(`[AsyncAgent] Adding subscriber for agent: ${this.sessionId}`); - const unsubscribe = this.agent.subscribeAll((event) => { - if (!this.shouldForwardEvent(event)) return; - console.log(`[AsyncAgent] Event received: ${event.type}`); - callback(event); - }); + const unsubscribe = this.agent.subscribeAll( + this.createFilteredHandler((event) => { + console.log(`[AsyncAgent] Event received: ${event.type}`); + callback(event); + }), + ); return () => { console.log(`[AsyncAgent] Removing subscriber for agent: ${this.sessionId}`); unsubscribe(); @@ -164,6 +205,43 @@ export class AsyncAgent { return this.queue; } + /** Error message from the last run, if it failed. */ + get lastRunError(): string | undefined { + return this._lastRunError; + } + + /** Whether the agent is currently executing a run (normal or internal). */ + get isRunning(): boolean { + return this.agent.isRunning; + } + + /** Whether the underlying LLM is currently streaming a response. */ + get isStreaming(): boolean { + return this.agent.isStreaming; + } + + /** + * Steer the agent mid-run. Bypasses the serial queue and injects a message + * directly into the PiAgentCore steering queue. The message is delivered + * after the current tool execution completes, skipping remaining tool calls. + */ + steer(content: string): void { + this.agent.steer(content); + } + + /** + * Queue a follow-up message for after the current run finishes. + * Delivered only when the agent has no more tool calls or steering messages. + */ + followUp(content: string): void { + this.agent.followUp(content); + } + + /** Whether the underlying PiAgentCore has queued steer/followUp messages. */ + hasQueuedMessages(): boolean { + return this.agent.hasQueuedMessages(); + } + private shouldForwardEvent(event: AgentEvent | MulticaEvent): boolean { if (!this.agent.isInternalRun) return true; if (!this.forwardInternalAssistant) return false; @@ -176,6 +254,68 @@ export class AsyncAgent { return (maybeMessage as { role?: unknown }).role === "assistant"; } + /** + * Wrap a forwarding callback with shouldForwardEvent + heartbeat ACK suppression. + * + * Mirrors Hub's pattern: buffer `message_start` for assistant messages, then + * check subsequent events with `isHeartbeatAckEvent()`. If the message is a + * pure heartbeat ACK (e.g. "HEARTBEAT_OK"), suppress the entire sequence. + * Otherwise flush the buffered start and forward normally. + */ + private createFilteredHandler( + forward: (event: AgentEvent | MulticaEvent) => void, + ): (event: AgentEvent | MulticaEvent) => void { + let pendingStart: (AgentEvent | MulticaEvent) | null = null; + + return (event: AgentEvent | MulticaEvent) => { + if (!this.shouldForwardEvent(event)) return; + + const isAssistantMsg = this.isAssistantMessageEvent(event); + + if (!isAssistantMsg) { + // Non-assistant event: flush any pending start, then forward + if (pendingStart) { + forward(pendingStart); + pendingStart = null; + } + forward(event); + return; + } + + // Assistant message event — apply heartbeat ACK suppression + if (event.type === "message_start") { + pendingStart = event; + return; + } + + // Check if this is a heartbeat ACK on content/end events + if (isHeartbeatAckEvent(event)) { + if (event.type === "message_end") { + // Entire message was a heartbeat ACK — suppress it + pendingStart = null; + } + return; + } + + // Not a heartbeat ACK — flush buffered start if present, then forward + if (pendingStart) { + forward(pendingStart); + pendingStart = null; + } + forward(event); + }; + } + + /** Check if an event is an assistant message event (message_start/update/end with role=assistant) */ + private isAssistantMessageEvent(event: AgentEvent | MulticaEvent): boolean { + if (event.type !== "message_start" && event.type !== "message_update" && event.type !== "message_end") { + return false; + } + const maybeMessage = (event as { message?: unknown }).message; + if (!maybeMessage || typeof maybeMessage !== "object") return false; + return (maybeMessage as { role?: unknown }).role === "assistant"; + } + /** Register a callback to be invoked when the agent is closed */ onClose(callback: () => void): void { if (this._closed) { diff --git a/packages/core/src/agent/credentials-cli.ts b/packages/core/src/agent/credentials-cli.ts index 9fae8bbd..d5fa9a13 100644 --- a/packages/core/src/agent/credentials-cli.ts +++ b/packages/core/src/agent/credentials-cli.ts @@ -44,7 +44,8 @@ function buildCoreTemplate(): string { }, tools: { // brave: { apiKey: "brv-..." }, - // perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" } + // perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" }, + // data: { apiKey: "your-financial-datasets-api-key" } } } `; diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index beaa4faf..636a263d 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -91,6 +91,7 @@ export class Agent { // Internal run state private _internalRun = false; + private _isRunning = false; private _runMutex: Promise = Promise.resolve(); private currentUserDisplayPrompt: string | undefined; @@ -304,8 +305,8 @@ export class Agent { const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools); const profileDir = this.profile?.getProfileDir(); this.toolsOptions = mergedToolsConfig - ? { ...options, tools: mergedToolsConfig, profileDir } - : { ...options, profileDir }; + ? { ...options, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider } + : { ...options, profileDir, provider: this.resolvedProvider }; const tools = resolveTools(this.toolsOptions); if (this.debug) { @@ -427,6 +428,7 @@ export class Agent { this.refreshAuthState(); this.output.state.lastAssistantText = ""; this.currentUserDisplayPrompt = options?.displayPrompt; + this._isRunning = true; try { // Early validation: check API key before calling PiAgentCore.prompt(), @@ -489,6 +491,7 @@ export class Agent { : undefined; return { text: this.output.state.lastAssistantText, thinking, error: this.agent.state.error }; } finally { + this._isRunning = false; this.currentUserDisplayPrompt = undefined; } } @@ -664,6 +667,40 @@ export class Agent { return this._internalRun; } + /** Whether a run (normal or internal) is currently executing inside _run(). */ + get isRunning(): boolean { + return this._isRunning; + } + + /** Whether the underlying PiAgentCore is currently streaming an LLM response. */ + get isStreaming(): boolean { + return this.agent.state.isStreaming; + } + + /** + * Queue a steering message to interrupt the agent mid-run. + * Delivered after current tool execution, skipping remaining tool calls. + * Safe to call from any context (does not require the run mutex). + */ + steer(content: string): void { + const msg: UserMessage = { role: "user", content, timestamp: Date.now() }; + this.agent.steer(msg); + } + + /** + * Queue a follow-up message for after the current run finishes. + * Delivered only when the agent has no more tool calls or steering messages. + */ + followUp(content: string): void { + const msg: UserMessage = { role: "user", content, timestamp: Date.now() }; + this.agent.followUp(msg); + } + + /** Whether the underlying PiAgentCore has queued steer/followUp messages. */ + hasQueuedMessages(): boolean { + return this.agent.hasQueuedMessages(); + } + /** * Persist a synthetic assistant message into both in-memory state and session JSONL. * Used after an internal run to keep the LLM summary visible in future turns @@ -895,6 +932,13 @@ export class Agent { // Update internal state this.resolvedProvider = providerId; + // Keep toolsOptions.provider in sync so sessions_spawn inherits the current provider + this.toolsOptions = { ...this.toolsOptions, provider: providerId }; + + // Reload tools so sessions_spawn picks up the new provider in its closure. + // Without this, the existing tool instance still captures the old provider. + const tools = resolveTools(this.toolsOptions); + this.agent.setTools(tools); // Update session metadata (save original providerId, not alias-resolved) this.session.saveMeta({ @@ -906,7 +950,7 @@ export class Agent { }); // Rebuild system prompt so runtime info reflects the new provider/model - const toolNames = (this.agent.state.tools ?? []).map((t: { name: string }) => t.name); + const toolNames = tools.map((t) => t.name); const systemPrompt = this.rebuildSystemPrompt(toolNames); if (systemPrompt) { this.agent.setSystemPrompt(systemPrompt); diff --git a/packages/core/src/agent/subagent/README.md b/packages/core/src/agent/subagent/README.md new file mode 100644 index 00000000..e007eab1 --- /dev/null +++ b/packages/core/src/agent/subagent/README.md @@ -0,0 +1,172 @@ +# Subagent System + +The subagent system allows a parent agent to spawn isolated child agents that run tasks in parallel and report results back automatically. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Parent Agent (runner.ts) │ +│ │ +│ tools: sessions_spawn, sessions_list │ +│ state: resolvedProvider, toolsOptions │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + │ sessions_spawn(task, label, timeoutSeconds) + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Spawn Flow (sessions-spawn.ts) │ +│ │ +│ 1. Build subagent system prompt (announce.ts) │ +│ 2. hub.createSubagent(childSessionId, { provider, model }) │ +│ 3. registerSubagentRun({ start: () => childAgent.write(task) }) │ +│ 4. Return { status: "accepted", runId, childSessionId } │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Concurrency Queue (command-queue.ts) │ +│ │ +│ Lane: "subagent" — max 10 concurrent (configurable) │ +│ Queued runs wait for a slot before start() is called │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ slot acquired + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Child Agent Execution │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ AsyncAgent (async-agent.ts) │ │ +│ │ - Isolated session with restricted tools (isSubagent=true) │ │ +│ │ - Inherits parent's LLM provider │ │ +│ │ - System prompt: task focus + error reporting rules │ │ +│ │ - Tracks lastRunError for error propagation │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ watchChildAgent (registry.ts) │ │ +│ │ - Sets startedAt, starts timeout timer │ │ +│ │ - waitForIdle() — waits for child's task queue to drain │ │ +│ │ - onClose() — handles explicit close (timeout kill, etc.) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + │ child completes / errors / times out + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Completion Handling (registry.ts) │ +│ │ +│ handleRunCompletion(record) │ +│ │ │ +│ ├─ Phase 1: captureFindings() │ +│ │ - Read last assistant reply from child session JSONL │ +│ │ - Falls back to last toolResult if no assistant text │ +│ │ - Persists findings to record before session deletion │ +│ │ │ +│ ├─ Session Cleanup │ +│ │ - cleanup="delete": rm child session dir + hub.closeAgent() │ +│ │ - cleanup="keep": preserve for audit │ +│ │ │ +│ └─ Phase 2: checkAndAnnounce(requesterSessionId) │ +│ - Finds all unannounced, completed runs with findings │ +│ - Calls runCoalescedAnnounceFlow() │ +│ - Marks records: announced=true, archiveAtMs=now+60min │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Announcement Delivery (announce.ts) │ +│ │ +│ runCoalescedAnnounceFlow(requesterSessionId, records) │ +│ │ │ +│ ├─ Format message: formatCoalescedAnnouncementMessage() │ +│ │ - Single record: task name, status, findings, stats │ +│ │ - Multiple records: combined report with all findings │ +│ │ │ +│ ├─ Two-tier delivery: │ +│ │ │ +│ │ Tier 1: BUSY (parent running or has pending writes) │ +│ │ └─ enqueueAnnounce() → announce-queue.ts │ +│ │ - Debounce 1s to batch nearby completions │ +│ │ - Drain via writeInternal() when parent finishes │ +│ │ │ +│ │ Tier 2: IDLE (parent not running) │ +│ │ └─ sendAnnounceDirect() │ +│ │ - writeInternal(msg, { forwardAssistant, persistResponse })│ +│ │ │ +│ └─ All delivery uses writeInternal() (marks as internal: true) │ +│ → Prevents announcement from showing as user bubble in UI │ +│ → LLM processes findings and responds naturally to user │ +└──────────┬──────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Record Lifecycle (registry.ts) │ +│ │ +│ created → startedAt → endedAt → findingsCaptured → announced │ +│ │ +│ After announcement: │ +│ - Record kept with archiveAtMs = now + 60 min │ +│ - sessions_list can still query records during this window │ +│ - Sweeper runs every 60s, removes expired records │ +│ - When all records removed, sweeper stops │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `sessions-spawn.ts` | Tool: spawns a child agent with task, label, timeout, provider | +| `sessions-list.ts` | Tool: lists subagent runs and their status | +| `registry.ts` | Lifecycle management: register, watch, capture, announce, archive | +| `announce.ts` | System prompt builder, findings reader, message formatter, delivery | +| `announce-queue.ts` | Debounced queue for batching announcements when parent is busy | +| `command-queue.ts` | Concurrency limiter for subagent lane slots | +| `lanes.ts` | Lane config: max concurrency (10), default timeout (600s) | +| `types.ts` | Shared types: SubagentRunRecord, SubagentRunOutcome, etc. | +| `registry-store.ts` | Persistence: save/load runs to disk for crash recovery | + +## Provider Inheritance + +Subagents inherit the parent's resolved LLM provider: + +``` +runner.ts (resolvedProvider) + → toolsOptions.provider + → tools.ts (CreateToolsOptions.provider) + → sessions-spawn.ts (options.provider) + → hub.createSubagent({ provider }) +``` + +When the user switches providers via UI (`setProvider()`), `toolsOptions.provider` is updated in sync so future spawns use the new provider. + +## Error Propagation + +``` +Child tool error (e.g., API 401) + → Subagent LLM sees error, includes in final message (system prompt rule) + → captureFindings() reads final message + → Announcement includes error in findings + → Parent LLM sees error and can inform user + +Child run error (e.g., missing API key for provider) + → AsyncAgent._lastRunError set + → registry.ts checks childAgent.lastRunError after waitForIdle() + → outcome = { status: "error", error: "No API key configured..." } + → Announcement: "task failed: No API key configured..." +``` + +## Timeout Behavior + +Default: 600s (10 min). System prompt guides the parent LLM: +- Simple tasks: 600s (default) +- Moderate tasks: 900-1200s (15-20 min) +- Complex tasks: 1200-1800s (20-30 min) + +On timeout: +1. Timeout timer fires in `watchChildAgent()` +2. `cleanup({ status: "timeout" })` is called +3. Child agent is closed via `hub.closeAgent()` +4. Findings are captured from whatever the child wrote so far +5. Announcement reports "timed out" with partial findings diff --git a/packages/core/src/agent/subagent/announce-queue.test.ts b/packages/core/src/agent/subagent/announce-queue.test.ts new file mode 100644 index 00000000..bff56972 --- /dev/null +++ b/packages/core/src/agent/subagent/announce-queue.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + enqueueAnnounce, + resetAnnounceQueuesForTests, + getAnnounceQueueDepth, + type AnnounceQueueItem, + type AnnounceQueueSettings, +} from "./announce-queue.js"; + +afterEach(() => { + resetAnnounceQueuesForTests(); +}); + +function makeItem(overrides?: Partial): AnnounceQueueItem { + return { + prompt: "test prompt", + summaryLine: "test summary", + enqueuedAt: Date.now(), + requesterSessionId: "session-1", + ...overrides, + }; +} + +const FAST_SETTINGS: AnnounceQueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 20, + dropPolicy: "old", +}; + +describe("announce queue", () => { + it("enqueues an item and drains via send callback", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + enqueueAnnounce({ + key: "test", + item: makeItem(), + settings: FAST_SETTINGS, + send, + }); + + // Wait for async drain + await new Promise((r) => setTimeout(r, 50)); + + expect(sent).toHaveLength(1); + expect(sent[0]!.prompt).toBe("test prompt"); + }); + + it("batches items in collect mode", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + const collectSettings: AnnounceQueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 20, + dropPolicy: "old", + }; + + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt 1" }), + settings: collectSettings, + send, + }); + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt 2" }), + settings: collectSettings, + send, + }); + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt 3" }), + settings: collectSettings, + send, + }); + + await new Promise((r) => setTimeout(r, 50)); + + // Collect mode batches all into one send + expect(sent).toHaveLength(1); + expect(sent[0]!.prompt).toContain("prompt 1"); + expect(sent[0]!.prompt).toContain("prompt 2"); + expect(sent[0]!.prompt).toContain("prompt 3"); + expect(sent[0]!.prompt).toContain("3 queued announce(s)"); + }); + + it("sends items individually in followup mode", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt A" }), + settings: FAST_SETTINGS, + send, + }); + enqueueAnnounce({ + key: "test", + item: makeItem({ prompt: "prompt B" }), + settings: FAST_SETTINGS, + send, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(sent).toHaveLength(2); + expect(sent[0]!.prompt).toBe("prompt A"); + expect(sent[1]!.prompt).toBe("prompt B"); + }); + + it("respects cap with 'new' drop policy (rejects new items)", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { + // Slow send to keep items in queue + await new Promise((r) => setTimeout(r, 200)); + sent.push(item); + }; + + const cappedSettings: AnnounceQueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 2, + dropPolicy: "new", + }; + + const r1 = enqueueAnnounce({ key: "test", item: makeItem({ prompt: "1" }), settings: cappedSettings, send }); + const r2 = enqueueAnnounce({ key: "test", item: makeItem({ prompt: "2" }), settings: cappedSettings, send }); + const r3 = enqueueAnnounce({ key: "test", item: makeItem({ prompt: "3" }), settings: cappedSettings, send }); + + expect(r1).toBe(true); + expect(r2).toBe(true); + expect(r3).toBe(false); // Rejected — cap reached + }); + + it("respects cap with 'old' drop policy (drops oldest)", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { + await new Promise((r) => setTimeout(r, 200)); + sent.push(item); + }; + + const cappedSettings: AnnounceQueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 2, + dropPolicy: "old", + }; + + enqueueAnnounce({ key: "test", item: makeItem({ prompt: "1" }), settings: cappedSettings, send }); + enqueueAnnounce({ key: "test", item: makeItem({ prompt: "2" }), settings: cappedSettings, send }); + enqueueAnnounce({ key: "test", item: makeItem({ prompt: "3" }), settings: cappedSettings, send }); + + // Queue should have items 2 and 3 (oldest was dropped) + expect(getAnnounceQueueDepth("test")).toBeLessThanOrEqual(2); + }); + + it("cleans up queue after drain completes", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + enqueueAnnounce({ + key: "test", + item: makeItem(), + settings: FAST_SETTINGS, + send, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(sent).toHaveLength(1); + expect(getAnnounceQueueDepth("test")).toBe(0); + }); + + it("debounces before draining", async () => { + const sent: AnnounceQueueItem[] = []; + const send = async (item: AnnounceQueueItem) => { sent.push(item); }; + + const debouncedSettings: AnnounceQueueSettings = { + mode: "followup", + debounceMs: 100, + cap: 20, + dropPolicy: "old", + }; + + enqueueAnnounce({ + key: "test", + item: makeItem(), + settings: debouncedSettings, + send, + }); + + // Should not have sent yet (debounce) + await new Promise((r) => setTimeout(r, 30)); + expect(sent).toHaveLength(0); + + // Wait for debounce to complete + await new Promise((r) => setTimeout(r, 150)); + expect(sent).toHaveLength(1); + }); +}); diff --git a/packages/core/src/agent/subagent/announce-queue.ts b/packages/core/src/agent/subagent/announce-queue.ts new file mode 100644 index 00000000..8e88a346 --- /dev/null +++ b/packages/core/src/agent/subagent/announce-queue.ts @@ -0,0 +1,315 @@ +/** + * Announce queue for subagent result delivery. + * + * Handles queuing and batching of subagent announcements when the parent + * agent is busy. Supports debounce, cap, drop policy, and collect mode. + * + * Ported from OpenClaw (MIT license), adapted for Super Multica. + */ + +// ============================================================================ +// Types +// ============================================================================ + +export type AnnounceQueueMode = + /** Try steer, no queue fallback */ + | "steer" + /** Try steer, fall back to queue */ + | "steer-backlog" + /** Queue and send items individually */ + | "followup" + /** Queue and batch all items into one combined prompt */ + | "collect"; + +export type AnnounceDropPolicy = + /** Drop oldest items when cap reached */ + | "old" + /** Drop newest items when cap reached */ + | "new" + /** Summarize dropped items */ + | "summarize"; + +export type AnnounceQueueItem = { + prompt: string; + summaryLine?: string; + enqueuedAt: number; + requesterSessionId: string; +}; + +export type AnnounceQueueSettings = { + mode: AnnounceQueueMode; + debounceMs?: number; + cap?: number; + dropPolicy?: AnnounceDropPolicy; +}; + +type AnnounceQueueState = { + items: AnnounceQueueItem[]; + draining: boolean; + lastEnqueuedAt: number; + mode: AnnounceQueueMode; + debounceMs: number; + cap: number; + dropPolicy: AnnounceDropPolicy; + droppedCount: number; + summaryLines: string[]; + send: (item: AnnounceQueueItem) => Promise; +}; + +// ============================================================================ +// Defaults +// ============================================================================ + +const DEFAULT_DEBOUNCE_MS = 1000; +const DEFAULT_CAP = 20; +const DEFAULT_DROP_POLICY: AnnounceDropPolicy = "summarize"; + +export const DEFAULT_ANNOUNCE_SETTINGS: AnnounceQueueSettings = { + mode: "steer-backlog", + debounceMs: DEFAULT_DEBOUNCE_MS, + cap: DEFAULT_CAP, + dropPolicy: DEFAULT_DROP_POLICY, +}; + +// ============================================================================ +// Module state +// ============================================================================ + +const ANNOUNCE_QUEUES = new Map(); + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Enqueue an announcement for delivery. Returns true if enqueued, + * false if dropped (cap + "new" drop policy). + */ +export function enqueueAnnounce(params: { + key: string; + item: AnnounceQueueItem; + settings: AnnounceQueueSettings; + send: (item: AnnounceQueueItem) => Promise; +}): boolean { + const queue = getOrCreateQueue(params.key, params.settings, params.send); + queue.lastEnqueuedAt = Date.now(); + + const shouldEnqueue = applyDropPolicy(queue, params.item); + if (!shouldEnqueue) { + if (queue.dropPolicy === "new") { + scheduleAnnounceDrain(params.key); + } + return false; + } + + queue.items.push(params.item); + scheduleAnnounceDrain(params.key); + return true; +} + +/** Reset all queues (for testing). */ +export function resetAnnounceQueuesForTests(): void { + ANNOUNCE_QUEUES.clear(); +} + +/** Get the current queue depth for a key (for testing/diagnostics). */ +export function getAnnounceQueueDepth(key: string): number { + return ANNOUNCE_QUEUES.get(key)?.items.length ?? 0; +} + +// ============================================================================ +// Queue management +// ============================================================================ + +function getOrCreateQueue( + key: string, + settings: AnnounceQueueSettings, + send: (item: AnnounceQueueItem) => Promise, +): AnnounceQueueState { + const existing = ANNOUNCE_QUEUES.get(key); + if (existing) { + existing.mode = settings.mode; + if (typeof settings.debounceMs === "number") { + existing.debounceMs = Math.max(0, settings.debounceMs); + } + if (typeof settings.cap === "number" && settings.cap > 0) { + existing.cap = Math.floor(settings.cap); + } + if (settings.dropPolicy) { + existing.dropPolicy = settings.dropPolicy; + } + existing.send = send; + return existing; + } + + const created: AnnounceQueueState = { + items: [], + draining: false, + lastEnqueuedAt: 0, + mode: settings.mode, + debounceMs: + typeof settings.debounceMs === "number" + ? Math.max(0, settings.debounceMs) + : DEFAULT_DEBOUNCE_MS, + cap: + typeof settings.cap === "number" && settings.cap > 0 + ? Math.floor(settings.cap) + : DEFAULT_CAP, + dropPolicy: settings.dropPolicy ?? DEFAULT_DROP_POLICY, + droppedCount: 0, + summaryLines: [], + send, + }; + ANNOUNCE_QUEUES.set(key, created); + return created; +} + +// ============================================================================ +// Drop policy +// ============================================================================ + +function applyDropPolicy( + queue: AnnounceQueueState, + item: AnnounceQueueItem, +): boolean { + if (queue.items.length < queue.cap) { + return true; + } + + switch (queue.dropPolicy) { + case "new": + // Reject the incoming item + return false; + + case "old": { + // Drop the oldest item to make room + const dropped = queue.items.shift(); + if (dropped) { + queue.droppedCount++; + const summary = dropped.summaryLine?.trim() || dropped.prompt.slice(0, 80); + queue.summaryLines.push(summary); + } + return true; + } + + case "summarize": { + // Drop the oldest item but keep a summary + const dropped = queue.items.shift(); + if (dropped) { + queue.droppedCount++; + const summary = dropped.summaryLine?.trim() || dropped.prompt.slice(0, 80); + queue.summaryLines.push(summary); + } + return true; + } + + default: + return true; + } +} + +// ============================================================================ +// Drain scheduling +// ============================================================================ + +function scheduleAnnounceDrain(key: string): void { + const queue = ANNOUNCE_QUEUES.get(key); + if (!queue || queue.draining) return; + + queue.draining = true; + void (async () => { + try { + while (queue.items.length > 0 || queue.droppedCount > 0) { + await waitForDebounce(queue); + + if (queue.mode === "collect") { + // Batch all items into one combined prompt + const items = queue.items.splice(0, queue.items.length); + const summary = buildDropSummary(queue); + const prompt = buildCollectPrompt(items, summary); + const last = items.at(-1); + if (!last) break; + await queue.send({ ...last, prompt }); + continue; + } + + // followup / steer-backlog: send items individually + const summary = buildDropSummary(queue); + if (summary) { + const next = queue.items.shift(); + if (!next) break; + await queue.send({ ...next, prompt: summary }); + continue; + } + + const next = queue.items.shift(); + if (!next) break; + await queue.send(next); + } + } catch (err) { + console.error(`[AnnounceQueue] Drain failed for ${key}: ${String(err)}`); + } finally { + queue.draining = false; + if (queue.items.length === 0 && queue.droppedCount === 0) { + ANNOUNCE_QUEUES.delete(key); + } else { + scheduleAnnounceDrain(key); + } + } + })(); +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function waitForDebounce(queue: AnnounceQueueState): Promise { + const elapsed = Date.now() - queue.lastEnqueuedAt; + const remaining = Math.max(0, queue.debounceMs - elapsed); + if (remaining <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, remaining)); +} + +function buildDropSummary(queue: AnnounceQueueState): string | undefined { + if (queue.droppedCount === 0) return undefined; + + const parts: string[] = [ + `[${queue.droppedCount} earlier announce(s) were summarized due to queue backlog]`, + ]; + if (queue.summaryLines.length > 0) { + parts.push(""); + for (const line of queue.summaryLines) { + parts.push(`- ${line}`); + } + } + + // Reset counters + queue.droppedCount = 0; + queue.summaryLines = []; + + return parts.join("\n"); +} + +function buildCollectPrompt( + items: AnnounceQueueItem[], + dropSummary: string | undefined, +): string { + const parts: string[] = [ + `[${items.length} queued announce(s) while agent was busy]`, + "", + ]; + + for (let i = 0; i < items.length; i++) { + parts.push(`---`); + parts.push(`Queued #${i + 1}`); + parts.push(items[i]!.prompt); + parts.push(""); + } + + if (dropSummary) { + parts.push(dropSummary); + parts.push(""); + } + + return parts.join("\n"); +} diff --git a/packages/core/src/agent/subagent/announce.ts b/packages/core/src/agent/subagent/announce.ts index 54c92e75..c1cf3330 100644 --- a/packages/core/src/agent/subagent/announce.ts +++ b/packages/core/src/agent/subagent/announce.ts @@ -16,6 +16,7 @@ import type { SubagentRunRecord, SubagentSystemPromptParams, } from "./types.js"; +import { enqueueAnnounce, DEFAULT_ANNOUNCE_SETTINGS } from "./announce-queue.js"; /** * Build the system prompt injected into a subagent session. @@ -275,7 +276,15 @@ export function formatCoalescedAnnouncementMessage( /** * Run the coalesced announcement flow for all completed runs of a requester. - * Formats a single combined message and delivers it to the parent agent. + * Uses two-tier delivery: + * 1. Queue — if parent is busy (running or has pending writes), queue for + * later drain via writeInternal (with debounce to batch nearby completions) + * 2. Direct — if parent is idle, send immediately via writeInternal + * + * All delivery uses writeInternal() which marks the announcement prompt as + * `internal: true` (hidden from UI). The assistant's summary response is + * forwarded to the real-time stream (`forwardAssistant: true`) so the user + * sees the result, and persisted to JSONL for future session loads. */ export function runCoalescedAnnounceFlow( requesterSessionId: string, @@ -293,7 +302,28 @@ export function runCoalescedAnnounceFlow( return false; } - parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true }); + // Tier 1: BUSY — parent is running or has pending writes + // Queue the announcement for delivery via writeInternal() after the parent + // finishes its current work. We do NOT use steer() (cancels unrelated tool + // calls) or followUp() (doesn't mark entries as internal, polluting the UI). + if (parentAgent.isRunning || parentAgent.getPendingWrites() > 0) { + enqueueAnnounce({ + key: requesterSessionId, + item: { + prompt: message, + summaryLine: `${records.length} subagent(s) completed`, + enqueuedAt: Date.now(), + requesterSessionId, + }, + settings: DEFAULT_ANNOUNCE_SETTINGS, + send: async (item) => sendAnnounceDirect(requesterSessionId, item.prompt), + }); + console.log(`[SubagentAnnounce] Queued announcement for parent: ${requesterSessionId}`); + return true; + } + + // Tier 2: IDLE — parent is idle, send directly via writeInternal + sendAnnounceDirect(requesterSessionId, message); return true; } catch (err) { console.error(`[SubagentAnnounce] Failed to coalesced-announce to parent:`, err); @@ -301,6 +331,26 @@ export function runCoalescedAnnounceFlow( } } +/** + * Send announcement directly to parent via writeInternal. + * Used as Tier 3 (idle) and as the queue drain callback. + */ +function sendAnnounceDirect(requesterSessionId: string, message: string): void { + try { + const hub = getHub(); + const parentAgent = hub.getAgent(requesterSessionId); + if (!parentAgent || parentAgent.closed) { + console.warn( + `[SubagentAnnounce] Parent agent not found or closed for direct send: ${requesterSessionId}`, + ); + return; + } + parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true }); + } catch (err) { + console.error(`[SubagentAnnounce] Failed direct announce to parent:`, err); + } +} + /** * Run the full subagent announcement flow: * 1. Read child's last assistant reply diff --git a/packages/core/src/agent/subagent/registry.test.ts b/packages/core/src/agent/subagent/registry.test.ts index 5d5eacc3..e1886dac 100644 --- a/packages/core/src/agent/subagent/registry.test.ts +++ b/packages/core/src/agent/subagent/registry.test.ts @@ -266,8 +266,105 @@ describe("subagent registry — coalescing", () => { }); }); +describe("subagent registry — silent announce mode", () => { + // Note: In tests (no Hub), watchChildAgent completes synchronously within + // registerSubagentRun(), so each run's lifecycle finishes before the next + // registration call. Multi-run coalescing requires async child agents and + // is validated in integration tests. + + it("stores announce field on the record", () => { + const record = registerSubagentRun({ + runId: "run-ann", + childSessionId: "child-ann", + requesterSessionId: "parent-1", + task: "Task", + announce: "silent", + }); + expect(record.announce).toBe("silent"); + }); + + it("defaults announce to undefined (immediate behavior)", () => { + const record = registerSubagentRun({ + runId: "run-def", + childSessionId: "child-def", + requesterSessionId: "parent-1", + task: "Task", + }); + expect(record.announce).toBeUndefined(); + }); + + it("silent runs are announced via runCoalescedAnnounceFlow", async () => { + const announceModule = await import("./announce.js"); + const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true); + + registerSubagentRun({ + runId: "run-s1", + childSessionId: "child-s1", + requesterSessionId: "parent-1", + task: "Silent A", + announce: "silent", + }); + + await flushQueue(); + + // Silent run announced (via runCoalescedAnnounceFlow mock) + const silentCalls = spy.mock.calls.filter( + ([reqId, records]) => + reqId === "parent-1" && + records.some((r: { announce?: string }) => r.announce === "silent"), + ); + expect(silentCalls.length).toBeGreaterThanOrEqual(1); + + const runS1 = getSubagentRun("run-s1"); + expect(runS1?.announced).toBe(true); + expect(runS1?.announce).toBe("silent"); + + spy.mockRestore(); + }); + + it("immediate and silent runs are never mixed in the same announce call", async () => { + const announceModule = await import("./announce.js"); + const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true); + + // Register immediate run, then silent run + registerSubagentRun({ + runId: "run-imm", + childSessionId: "child-imm", + requesterSessionId: "parent-1", + task: "Immediate task", + }); + registerSubagentRun({ + runId: "run-s1", + childSessionId: "child-s1", + requesterSessionId: "parent-1", + task: "Silent task", + announce: "silent", + }); + + await flushQueue(); + + const calls = spy.mock.calls.filter( + ([reqId]) => reqId === "parent-1", + ); + + // Immediate and silent should never be in the same announce call + const mixedCalls = calls.filter(([, records]) => { + const hasImm = records.some((r: { announce?: string }) => r.announce !== "silent"); + const hasSilent = records.some((r: { announce?: string }) => r.announce === "silent"); + return hasImm && hasSilent; + }); + expect(mixedCalls).toHaveLength(0); + + // Both should be announced (in separate calls) + expect(getSubagentRun("run-imm")?.announced).toBe(true); + expect(getSubagentRun("run-s1")?.announced).toBe(true); + + spy.mockRestore(); + }); +}); + describe("subagent registry — post-announce cleanup", () => { - it("removes runs from registry after successful announcement", async () => { + it("keeps runs in registry after successful announcement with archiveAtMs", async () => { // Mock runCoalescedAnnounceFlow to succeed const announceModule = await import("./announce.js"); const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true); @@ -288,11 +385,20 @@ describe("subagent registry — post-announce cleanup", () => { await flushQueue(); - // Both runs should have been announced and removed from registry + // Both runs should have been announced but kept in registry with archiveAtMs expect(spy).toHaveBeenCalled(); - expect(getSubagentRun("run-a")).toBeUndefined(); - expect(getSubagentRun("run-b")).toBeUndefined(); - expect(listSubagentRuns("parent-1")).toHaveLength(0); + + const runA = getSubagentRun("run-a"); + const runB = getSubagentRun("run-b"); + expect(runA).toBeDefined(); + expect(runB).toBeDefined(); + expect(runA!.announced).toBe(true); + expect(runB!.announced).toBe(true); + expect(runA!.archiveAtMs).toBeGreaterThan(Date.now()); + expect(runB!.archiveAtMs).toBeGreaterThan(Date.now()); + + // Records are still queryable + expect(listSubagentRuns("parent-1")).toHaveLength(2); spy.mockRestore(); }); diff --git a/packages/core/src/agent/subagent/registry.ts b/packages/core/src/agent/subagent/registry.ts index f40a72c0..c19a5529 100644 --- a/packages/core/src/agent/subagent/registry.ts +++ b/packages/core/src/agent/subagent/registry.ts @@ -101,6 +101,7 @@ export function registerSubagentRun(params: RegisterSubagentRunParams): Subagent label, cleanup = "delete", timeoutSeconds, + announce, start, } = params; @@ -111,6 +112,7 @@ export function registerSubagentRun(params: RegisterSubagentRunParams): Subagent task, label, cleanup, + announce, createdAt: Date.now(), }; @@ -121,7 +123,9 @@ export function registerSubagentRun(params: RegisterSubagentRunParams): Subagent // Enqueue in the subagent lane — the start callback and watchChildAgent // only execute once a concurrency slot is available. void enqueueInLane(SubagentLane.Subagent, async () => { + console.log(`[SubagentRegistry] Lane slot acquired for ${runId}, calling start()`); start?.(); + console.log(`[SubagentRegistry] start() returned, entering watchChildAgent`); return watchChildAgent(record, timeoutSeconds); }); @@ -248,12 +252,26 @@ function watchChildAgent(record: SubagentRunRecord, timeoutSeconds?: number): Pr // Wait for the child agent's task queue to drain (task completion), // then trigger announce flow. Uses waitForIdle() instead of consuming // the stream (which would conflict with Hub.consumeAgent). + console.log(`[SubagentRegistry] waitForIdle() called for child ${childSessionId}, pendingWrites=${childAgent.getPendingWrites()}`); childAgent.waitForIdle().then( - () => cleanup({ status: "ok" }), - (err) => cleanup({ - status: "error", - error: err instanceof Error ? err.message : String(err), - }), + () => { + const runtime = Date.now() - (record.startedAt ?? 0); + const runError = childAgent.lastRunError; + if (runError) { + console.log(`[SubagentRegistry] waitForIdle() resolved for child ${childSessionId} with error (runtime: ${runtime}ms): ${runError}`); + cleanup({ status: "error", error: runError }); + } else { + console.log(`[SubagentRegistry] waitForIdle() resolved OK for child ${childSessionId} (runtime: ${runtime}ms)`); + cleanup({ status: "ok" }); + } + }, + (err) => { + console.error(`[SubagentRegistry] waitForIdle() rejected for child ${childSessionId}:`, err); + cleanup({ + status: "error", + error: err instanceof Error ? err.message : String(err), + }); + }, ); // Also handle explicit close (e.g., timeout kill, Hub shutdown) @@ -280,43 +298,57 @@ function captureFindings(record: SubagentRunRecord): void { } /** - * Phase 2: Check if all unannounced runs for this requester have completed. - * If so, send a single coalesced announcement to the parent. + * Phase 2: Announce completed-but-unannounced runs. + * + * Runs with announce="silent" are held back until ALL silent runs from the + * same requester have completed. All other runs (immediate / undefined) are + * announced per-completion as before. */ function checkAndAnnounce(requesterSessionId: string): void { const allRuns = listSubagentRuns(requesterSessionId); - // Only consider unannounced runs - const pending = allRuns.filter(r => !r.announced); - if (pending.length === 0) return; + // ── Immediate runs: announce per-completion (default behavior) ── + const immediateReady = allRuns.filter( + r => !r.announced && r.endedAt !== undefined && r.findingsCaptured && r.announce !== "silent", + ); + if (immediateReady.length > 0) { + announceGroup(requesterSessionId, immediateReady); + } - // Are all unannounced runs done? - const allDone = pending.every(r => r.endedAt !== undefined); - if (!allDone) return; + // ── Silent runs: announce only when ALL silent runs are done ── + const silentRuns = allRuns.filter(r => r.announce === "silent"); + const unannouncedSilent = silentRuns.filter(r => !r.announced); + const silentReady = unannouncedSilent.filter( + r => r.endedAt !== undefined && r.findingsCaptured, + ); - // Have all had findings captured? - const allCaptured = pending.every(r => r.findingsCaptured); - if (!allCaptured) return; + // All unannounced silent runs must be ready (ended + findings captured) + if (silentReady.length > 0 && silentReady.length === unannouncedSilent.length) { + announceGroup(requesterSessionId, silentReady); + } +} - // All done — send coalesced announcement - const announced = runCoalescedAnnounceFlow(requesterSessionId, pending); +/** Announce a group of runs and mark them as announced. */ +function announceGroup(requesterSessionId: string, runs: SubagentRunRecord[]): void { + const announced = runCoalescedAnnounceFlow(requesterSessionId, runs); if (announced) { - for (const r of pending) { + for (const r of runs) { r.announced = true; r.cleanupHandled = true; - // Remove from registry immediately — findings already delivered to parent - subagentRuns.delete(r.runId); + // Keep records for querying via sessions_list; let sweeper archive later + r.archiveAtMs = Date.now() + DEFAULT_ARCHIVE_AFTER_MS; } persist(); - if (subagentRuns.size === 0) { - stopSweeper(); - } } else { + // Allow retry — mark cleanupHandled false so initSubagentRegistry() retries + for (const r of runs) { + r.cleanupHandled = false; + } + persist(); console.warn( - `[SubagentRegistry] Coalesced announce failed for requester ${requesterSessionId}`, + `[SubagentRegistry] Announce failed for requester ${requesterSessionId}`, ); - // Leave announced=false so initSubagentRegistry() can retry on restart } } diff --git a/packages/core/src/agent/subagent/types.ts b/packages/core/src/agent/subagent/types.ts index 1d3ca0ac..96277181 100644 --- a/packages/core/src/agent/subagent/types.ts +++ b/packages/core/src/agent/subagent/types.ts @@ -45,6 +45,9 @@ export type SubagentRunRecord = { findingsCaptured?: boolean | undefined; /** Whether the coalesced announcement has been sent to parent */ announced?: boolean | undefined; + /** Announcement mode: "immediate" (default) announces per-completion, + * "silent" defers until all silent runs from the same requester complete. */ + announce?: "immediate" | "silent" | undefined; }; /** Parameters for registering a new subagent run */ @@ -58,6 +61,8 @@ export type RegisterSubagentRunParams = { timeoutSeconds?: number | undefined; /** Callback invoked when the queue slot is acquired (used to defer childAgent.write). */ start?: (() => void) | undefined; + /** Announcement mode: "immediate" (default) or "silent" (defer until all silent runs complete). */ + announce?: "immediate" | "silent" | undefined; }; /** Parameters for the announce flow */ diff --git a/packages/core/src/agent/system-prompt/sections.test.ts b/packages/core/src/agent/system-prompt/sections.test.ts index 1830f9fc..df446356 100644 --- a/packages/core/src/agent/system-prompt/sections.test.ts +++ b/packages/core/src/agent/system-prompt/sections.test.ts @@ -176,7 +176,18 @@ describe("buildConditionalToolSections", () => { it("includes web access section when web tools present", () => { const result = buildConditionalToolSections(["web_search"], "full"); - expect(result.join("\n")).toContain("## Web Access"); + const text = result.join("\n"); + expect(text).toContain("## Web Access"); + expect(text).toContain("Web usage is conditional, not mandatory"); + }); + + it("adds dynamic evidence decision guidance when data tool is present", () => { + const result = buildConditionalToolSections(["data"], "full"); + const text = result.join("\n"); + expect(text).toContain("## Data Access"); + expect(text).toContain("dynamic evidence decision"); + expect(text).toContain("Make this evidence decision internally"); + expect(text).toContain("user-facing research rationale"); }); it("returns empty when no conditional tools match", () => { diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index f0db6e33..70d1b7f1 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 ─────────────────────────────────────────────────────── @@ -260,9 +262,37 @@ export function buildConditionalToolSections( lines.push( "## Sub-Agents", "If a task is complex or long-running, spawn a sub-agent. It will do the work and report back when done.", - "You can check on running sub-agents at any time.", + "IMPORTANT: After spawning sub-agents, do NOT immediately check on them with sessions_list. " + + "Results are delivered directly into your context automatically when the sub-agent finishes. " + + "Continue with other tasks or finish your turn and wait for the results to arrive.", + "You may use sessions_list to check on sub-agents only if a long time has passed or the user explicitly asks about their status.", "Sub-agents cannot spawn nested sub-agents.", "", + "### Timeout Guidelines", + "Set timeoutSeconds generously — a sub-agent that times out loses all its work.", + "- Simple tasks (search, read, summarize): 600 (10 min, the default)", + "- Moderate tasks (multi-step research, file downloads + analysis): 900–1200 (15–20 min)", + "- Complex tasks (code generation, PDF creation, multi-file operations): 1200–1800 (20–30 min)", + "When in doubt, use a longer timeout. It is always better to wait longer than to lose completed work.", + "", + "### Announce Modes", + "- `announce: \"immediate\"` (default): Each sub-agent's findings are delivered to you as soon as it completes.", + "- `announce: \"silent\"`: All findings are held back until every silent sub-agent finishes, then delivered as ONE combined report.", + "Use \"silent\" when you want to collect data from multiple sub-agents first, then summarize everything at once.", + "", + ); + } + + // 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.", + "When both data and web tools are available, make a dynamic evidence decision: start from structured data, and use web tools only when external validation is needed (for example: event-driven, time-sensitive, or conflicting/incomplete evidence).", + "Make this evidence decision internally. In final answers, present concise user-facing research rationale instead of technical decision labels unless the user asks for methodology details.", + "", ); } @@ -272,6 +302,7 @@ export function buildConditionalToolSections( "## Web Access", "You have web access. Use it when the user asks about current events, needs up-to-date information, or requests content from URLs.", "Prefer web_search for discovery and web_fetch for specific URLs.", + "Web usage is conditional, not mandatory: call web tools when they materially improve evidence quality.", "", ); } @@ -364,6 +395,10 @@ export function buildSubagentSection( "## Subagent Rules", "- Stay focused on the assigned task below.", "- Complete the task thoroughly and report your findings.", + "- If you encounter errors (missing API keys, permission denied, tool failures, etc.), " + + "you MUST explicitly report them in your final message. " + + "State exactly what failed and what is needed to fix it — " + + "the parent agent relies on your final message to understand what happened.", "- Do NOT initiate side actions unrelated to the task.", "- Do NOT attempt to communicate with the user directly.", "- Do NOT spawn nested subagents.", diff --git a/packages/core/src/agent/tokens.test.ts b/packages/core/src/agent/tokens.test.ts new file mode 100644 index 00000000..d15f1d4f --- /dev/null +++ b/packages/core/src/agent/tokens.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "./tokens.js"; + +describe("isSilentReplyText", () => { + it("detects exact NO_REPLY", () => { + expect(isSilentReplyText("NO_REPLY")).toBe(true); + }); + + it("detects NO_REPLY with surrounding whitespace", () => { + expect(isSilentReplyText(" NO_REPLY ")).toBe(true); + expect(isSilentReplyText("\nNO_REPLY\n")).toBe(true); + }); + + it("detects NO_REPLY with trailing punctuation", () => { + expect(isSilentReplyText("NO_REPLY.")).toBe(true); + expect(isSilentReplyText("NO_REPLY.\n")).toBe(true); + }); + + it("detects NO_REPLY at end of text", () => { + expect(isSilentReplyText("I have nothing to report. NO_REPLY")).toBe(true); + }); + + it("returns false for undefined/empty", () => { + expect(isSilentReplyText(undefined)).toBe(false); + expect(isSilentReplyText("")).toBe(false); + }); + + it("returns false for normal text", () => { + expect(isSilentReplyText("Here are the findings")).toBe(false); + }); + + it("returns false for NO_REPLY embedded in a word", () => { + expect(isSilentReplyText("DONO_REPLYX")).toBe(false); + }); + + it("exports SILENT_REPLY_TOKEN as NO_REPLY", () => { + expect(SILENT_REPLY_TOKEN).toBe("NO_REPLY"); + }); +}); diff --git a/packages/core/src/agent/tokens.ts b/packages/core/src/agent/tokens.ts new file mode 100644 index 00000000..2ec4136f --- /dev/null +++ b/packages/core/src/agent/tokens.ts @@ -0,0 +1,17 @@ +export const SILENT_REPLY_TOKEN = "NO_REPLY"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function isSilentReplyText( + text: string | undefined, + token: string = SILENT_REPLY_TOKEN, +): boolean { + if (!text) return false; + const escaped = escapeRegExp(token); + const prefix = new RegExp(`^\\s*${escaped}(?=$|\\W)`); + if (prefix.test(text)) return true; + const suffix = new RegExp(`\\b${escaped}\\b\\W*$`); + return suffix.test(text); +} diff --git a/packages/core/src/agent/tools.ts b/packages/core/src/agent/tools.ts index f3908d73..edc43dd2 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"; @@ -26,6 +27,8 @@ export interface CreateToolsOptions { isSubagent?: boolean | undefined; /** Session ID of the agent (passed to sessions_spawn tool) */ sessionId?: string | undefined; + /** Resolved provider ID of the parent agent (passed to sessions_spawn for subagent inheritance) */ + provider?: string | undefined; /** Callback invoked when exec tool needs approval before running a command */ onExecApprovalNeeded?: ExecApprovalCallback | undefined; } @@ -110,6 +113,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< const webSearchTool = createWebSearchTool(); const cronTool = createCronTool(); + const dataTool = createDataTool(); const tools: AgentTool[] = [ ...baseTools, @@ -119,6 +123,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 @@ -131,6 +136,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< const sessionsSpawnTool = createSessionsSpawnTool({ isSubagent: isSubagent ?? false, ...(sessionId !== undefined ? { sessionId } : {}), + ...(opts.provider !== undefined ? { provider: opts.provider } : {}), }); tools.push(sessionsSpawnTool as AgentTool); @@ -165,6 +171,7 @@ export function resolveTools(options: ResolveToolsOptions): AgentTool[] { profileDir: options.profileDir, isSubagent: options.isSubagent, sessionId: options.sessionId, + provider: options.provider, onExecApprovalNeeded: options.onExecApprovalNeeded, }); 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 00000000..b4bb2e99 --- /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 00000000..557b9fcf --- /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 00000000..e869f655 --- /dev/null +++ b/packages/core/src/agent/tools/data/finance/api.ts @@ -0,0 +1,79 @@ +/** + * 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 { + // 1. credentials.json5 → tools.data.apiKey (preferred) + const toolConfig = credentialManager.getToolConfig("data"); + if (toolConfig?.apiKey) return toolConfig.apiKey; + + // 2. Fallback: env var (skills.env.json5 or process.env) + const envKey = credentialManager.getEnv("FINANCIAL_DATASETS_API_KEY"); + if (envKey) return envKey; + + throw new Error( + "Financial Datasets API key not configured. " + + 'Set it in ~/.super-multica/credentials.json5 under tools.data.apiKey, ' + + "or set FINANCIAL_DATASETS_API_KEY in ~/.super-multica/skills.env.json5.", + ); +} + +/** + * 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 00000000..82fa4342 --- /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 00000000..7712b081 --- /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 f61c9037..a2bbed12 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 e225c54c..3e148e1a 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 diff --git a/packages/core/src/agent/tools/sessions-list.test.ts b/packages/core/src/agent/tools/sessions-list.test.ts index a0bf6c1a..989493de 100644 --- a/packages/core/src/agent/tools/sessions-list.test.ts +++ b/packages/core/src/agent/tools/sessions-list.test.ts @@ -73,9 +73,9 @@ describe("sessions_list tool", () => { const text = result.content[0]!; expect(text.type).toBe("text"); expect((text as { text: string }).text).toContain("3 total"); - expect((text as { text: string }).text).toContain("[running]"); - expect((text as { text: string }).text).toContain("[ok]"); - expect((text as { text: string }).text).toContain("[error]"); + expect((text as { text: string }).text).toContain("[RUNNING]"); + expect((text as { text: string }).text).toContain("[OK]"); + expect((text as { text: string }).text).toContain("[ERROR]"); expect((text as { text: string }).text).toContain("Code Review"); expect((text as { text: string }).text).toContain("Test Analysis"); expect((text as { text: string }).text).toContain("Lint Check"); diff --git a/packages/core/src/agent/tools/sessions-list.ts b/packages/core/src/agent/tools/sessions-list.ts index 19a0809e..988bfa1c 100644 --- a/packages/core/src/agent/tools/sessions-list.ts +++ b/packages/core/src/agent/tools/sessions-list.ts @@ -127,7 +127,9 @@ export function createSessionsListTool( label: "List Subagent Runs", description: "List all subagent runs spawned by this session and their current status. " + - "Optionally pass a runId to get detailed information about a specific run.", + "Optionally pass a runId to get detailed information about a specific run. " + + "NOTE: Do NOT call this immediately after spawning subagents — results arrive automatically in your context when subagents complete. " + + "Only use this if a long time has passed or the user explicitly asks about subagent status.", parameters: SessionsListSchema, execute: async (_toolCallId, args) => { const { runId } = args as SessionsListArgs; @@ -173,13 +175,52 @@ export function createSessionsListTool( }; } - const lines = [`Subagent runs for this session: ${runs.length} total`, ""]; + const someRunning = runs.some((r) => !r.endedAt); + + // Build status lines for each run + const statusLines: string[] = []; for (let i = 0; i < runs.length; i++) { - lines.push(formatRunSummary(runs[i]!, i, now)); + const r = runs[i]!; + const displayName = r.label || r.task.slice(0, 60); + const status = resolveStatus(r); + if (status === "running") { + const elapsed = r.startedAt ? formatElapsed(now - r.startedAt) : "just spawned"; + statusLines.push(` ${i + 1}. [RUNNING] "${displayName}" (${elapsed})`); + } else { + const elapsed = r.startedAt && r.endedAt ? formatElapsed(r.endedAt - r.startedAt) : ""; + const findings = r.findingsCaptured + ? (r.findings ? r.findings.slice(0, 200) + (r.findings.length > 200 ? "…" : "") : "(no output)") + : "(findings not yet captured)"; + statusLines.push(` ${i + 1}. [${status.toUpperCase()}] "${displayName}" (${elapsed})\n Findings: ${findings}`); + } } + const header = `Subagent runs for this session: ${runs.length} total`; + const body = statusLines.join("\n"); + + // If any subagents are still running, return status with wait instruction. + // We do NOT use steer() here — steer would cancel unrelated tool calls + // that the LLM may be processing in the same batch. + if (someRunning) { + const runningCount = runs.filter((r) => !r.endedAt).length; + return { + content: [ + { + type: "text", + text: + header + "\n" + body + "\n\n" + + `STATUS: ${runningCount} subagent(s) still running. This is normal — they need time to complete.\n` + + "ACTION REQUIRED: Do NOT call sessions_list again. Results will be delivered into your context automatically when they finish.\n" + + "Do NOT attempt to do this work yourself — the subagents are handling it.", + }, + ], + details: { runs: runs.map(toResultRun) }, + }; + } + + // All completed — normal response return { - content: [{ type: "text", text: lines.join("\n") }], + content: [{ type: "text", text: header + "\n" + body }], details: { runs: runs.map(toResultRun) }, }; }, diff --git a/packages/core/src/agent/tools/sessions-spawn.ts b/packages/core/src/agent/tools/sessions-spawn.ts index 35c0017b..1df31e41 100644 --- a/packages/core/src/agent/tools/sessions-spawn.ts +++ b/packages/core/src/agent/tools/sessions-spawn.ts @@ -35,6 +35,15 @@ const SessionsSpawnSchema = Type.Object({ minimum: 0, }), ), + announce: Type.Optional( + Type.Union([Type.Literal("immediate"), Type.Literal("silent")], { + description: + "Announcement mode. 'immediate' (default): findings delivered as each subagent completes. " + + "'silent': defer all announcements until every silent subagent from this session finishes, " + + "then deliver one combined report. Use 'silent' when spawning multiple subagents to collect " + + "data in parallel and you want to summarize everything at once.", + }), + ), }); type SessionsSpawnArgs = { @@ -43,6 +52,7 @@ type SessionsSpawnArgs = { model?: string; cleanup?: "delete" | "keep"; timeoutSeconds?: number; + announce?: "immediate" | "silent"; }; export type SessionsSpawnResult = { @@ -57,6 +67,8 @@ export interface CreateSessionsSpawnToolOptions { isSubagent?: boolean; /** Session ID of the current (requester) agent */ sessionId?: string; + /** Resolved provider ID of the parent agent (inherited by subagents) */ + provider?: string; } export function createSessionsSpawnTool( @@ -67,11 +79,13 @@ export function createSessionsSpawnTool( label: "Spawn Subagent", description: "Spawn a background subagent to handle a specific task. The subagent runs in an isolated session with its own tool set. " + - "When it completes, its findings are announced back to you automatically. " + + "When it completes, its findings are delivered directly into your context automatically — you do NOT need to poll or check. " + + "IMPORTANT: After spawning subagents, continue with any other immediate tasks you have, or simply finish your turn and wait. " + + "Do NOT call sessions_list to check on subagents you just spawned — results take time and will arrive on their own. " + "Use this for parallelizable work, long-running analysis, or tasks that benefit from isolation.", parameters: SessionsSpawnSchema, execute: async (_toolCallId, args) => { - const { task, label, model, cleanup = "delete", timeoutSeconds } = args as SessionsSpawnArgs; + const { task, label, model, cleanup = "delete", timeoutSeconds, announce } = args as SessionsSpawnArgs; // Guard: subagents cannot spawn subagents if (options.isSubagent) { @@ -107,6 +121,7 @@ export function createSessionsSpawnTool( const childAgent = hub.createSubagent(childSessionId, { systemPrompt, model, + provider: options.provider, }); // Register the run for lifecycle tracking. @@ -120,6 +135,7 @@ export function createSessionsSpawnTool( label, cleanup, timeoutSeconds, + announce, start: () => childAgent.write(task), }); @@ -127,7 +143,7 @@ export function createSessionsSpawnTool( content: [ { type: "text", - text: `Subagent spawned successfully.\n\nRun ID: ${runId}\nSession: ${childSessionId}\nTask: ${label || task.slice(0, 80)}\n\nThe subagent is now working in the background. You will receive its findings when it completes.`, + text: `Subagent spawned successfully.\n\nRun ID: ${runId}\nSession: ${childSessionId}\nTask: ${label || task.slice(0, 80)}\n\nThe subagent is now working in the background. Its findings will be delivered directly into your context when it completes — do NOT poll or call sessions_list for it. Continue with other tasks or finish your turn.`, }, ], details: { diff --git a/packages/core/src/heartbeat/runner.test.ts b/packages/core/src/heartbeat/runner.test.ts index 1a04bc5f..f6691a5c 100644 --- a/packages/core/src/heartbeat/runner.test.ts +++ b/packages/core/src/heartbeat/runner.test.ts @@ -8,9 +8,7 @@ type StubAgent = { closed: boolean; sessionId: string; ensureInitialized: () => Promise; - getMessages: () => Array; - write: (content: string, options?: { injectTimestamp?: boolean }) => void; - waitForIdle: () => Promise; + runInternalForResult: (content: string) => Promise<{ text: string; error?: string }>; getHeartbeatConfig: () => { prompt?: string; ackMaxChars?: number; enabled?: boolean }; getPendingWrites: () => number; getProfileDir: () => string | undefined; @@ -21,19 +19,13 @@ function createStubAgent(opts?: { replyText?: string; heartbeatEnabled?: boolean; }): StubAgent { - const messages: Array = []; const replyText = opts?.replyText ?? "HEARTBEAT_OK"; return { closed: false, sessionId: "test-session", ensureInitialized: async () => {}, - getMessages: () => messages, - write: (content: string) => { - messages.push({ role: "user", content }); - messages.push({ role: "assistant", content: [{ type: "text", text: replyText }] }); - }, - waitForIdle: async () => {}, + runInternalForResult: async () => ({ text: replyText }), getHeartbeatConfig: () => typeof opts?.heartbeatEnabled === "boolean" ? { enabled: opts.heartbeatEnabled } @@ -84,18 +76,18 @@ describe("heartbeat runner", () => { expect(result.status).toBe("ran"); }); - it("disables timestamp injection for heartbeat prompt writes", async () => { - const writes: Array<{ content: string; options?: { injectTimestamp?: boolean } }> = []; + it("uses runInternalForResult for heartbeat execution", async () => { + const calls: string[] = []; const agent = createStubAgent({ replyText: "HEARTBEAT_OK" }); - const originalWrite = agent.write; - agent.write = (content, options) => { - writes.push(options ? { content, options } : { content }); - originalWrite(content, options); + agent.runInternalForResult = async (content: string) => { + calls.push(content); + return { text: "HEARTBEAT_OK" }; }; await runHeartbeatOnce({ agent: agent as any, reason: "manual" }); - expect(writes.length).toBeGreaterThan(0); - expect(writes[0]?.options?.injectTimestamp).toBe(false); + expect(calls.length).toBeGreaterThan(0); + // The prompt should contain heartbeat instructions + expect(calls[0]).toContain("heartbeat"); }); }); diff --git a/packages/core/src/heartbeat/runner.ts b/packages/core/src/heartbeat/runner.ts index 7e6dab53..3ca4d7a0 100644 --- a/packages/core/src/heartbeat/runner.ts +++ b/packages/core/src/heartbeat/runner.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AsyncAgent } from "../agent/async-agent.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -73,22 +72,6 @@ function resolveDurationMs(raw: string | undefined): number | null { return null; } -function extractMessageText(message: AgentMessage | undefined): string { - if (!message) return ""; - const raw = (message as { content?: unknown }).content; - if (typeof raw === "string") return raw; - if (!Array.isArray(raw)) return ""; - - const parts: string[] = []; - for (const block of raw) { - if (!block || typeof block !== "object") continue; - const text = (block as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - parts.push(text); - } - } - return parts.join("\n").trim(); -} function getHeartbeatConfig(agent: AsyncAgent | null): HeartbeatConfig { const cfg = agent?.getHeartbeatConfig(); @@ -167,7 +150,6 @@ export async function runHeartbeatOnce(opts: { } await agent.ensureInitialized(); - const beforeMessages = agent.getMessages(); const sessionKey = resolveSessionKey(agent); const pendingEvents = drainSystemEvents(sessionKey); @@ -176,15 +158,11 @@ export async function runHeartbeatOnce(opts: { ? `${basePrompt}\n\nSystem events:\n${pendingEvents.map((line) => `- ${line}`).join("\n")}` : basePrompt; - agent.write(prompt, { injectTimestamp: false }); - await agent.waitForIdle(); - - const afterMessages = agent.getMessages(); - const appended = afterMessages.slice(beforeMessages.length); - const assistant = [...appended] - .reverse() - .find((msg) => msg.role === "assistant"); - const text = extractMessageText(assistant); + const result = await agent.runInternalForResult(prompt); + if (result.error) { + throw new Error(result.error); + } + const text = result.text; if (!text.trim()) { const okEmptyEvent: Omit = { diff --git a/packages/ui/package.json b/packages/ui/package.json index b33bd59a..4b9e7167 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "sideEffects": [ - "**/*.css", +"**/*.css", "./src/styles/fonts.ts" ], "scripts": { @@ -34,6 +34,7 @@ "@tiptap/starter-kit": "^3.19.0", "class-variance-authority": "catalog:", "clsx": "catalog:", + "katex": "^0.16.28", "linkify-it": "^5.0.0", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", @@ -41,8 +42,10 @@ "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "shadcn": "^3.7.0", "shiki": "^3.21.0", "sonner": "^2.0.7", diff --git a/packages/ui/src/components/markdown/Markdown.tsx b/packages/ui/src/components/markdown/Markdown.tsx index 52e22ddf..d201b9c1 100644 --- a/packages/ui/src/components/markdown/Markdown.tsx +++ b/packages/ui/src/components/markdown/Markdown.tsx @@ -1,10 +1,13 @@ import * as React from 'react' import ReactMarkdown, { type Components } from 'react-markdown' +import rehypeKatex from 'rehype-katex' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' import { cn } from '@multica/ui/lib/utils' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessLinks } from './linkify' +import 'katex/dist/katex.min.css' /** * Render modes for markdown content: @@ -270,8 +273,8 @@ export function Markdown({ return (
{processedContent} diff --git a/packages/ui/src/components/markdown/StreamingMarkdown.tsx b/packages/ui/src/components/markdown/StreamingMarkdown.tsx index fc50963f..86e2b72b 100644 --- a/packages/ui/src/components/markdown/StreamingMarkdown.tsx +++ b/packages/ui/src/components/markdown/StreamingMarkdown.tsx @@ -49,6 +49,7 @@ function splitIntoBlocks(content: string): Block[] { const lines = content.split('\n') let currentBlock = '' let inCodeBlock = false + let inMathBlock = false for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? '' @@ -73,6 +74,26 @@ function splitIntoBlocks(content: string): Block[] { } else if (inCodeBlock) { // Inside code block - append line currentBlock += line + '\n' + // Check for display math fence ($$) + } else if (line.trim() === '$$') { + if (!inMathBlock) { + // Starting a math block - flush current paragraph first + if (currentBlock.trim()) { + blocks.push({ content: currentBlock.trim(), isCodeBlock: false }) + currentBlock = '' + } + inMathBlock = true + currentBlock = line + '\n' + } else { + // Ending a math block + currentBlock += line + blocks.push({ content: currentBlock, isCodeBlock: false }) + currentBlock = '' + inMathBlock = false + } + } else if (inMathBlock) { + // Inside math block - append line (don't split on blank lines) + currentBlock += line + '\n' } else if (line === '') { // Empty line outside code block = paragraph boundary if (currentBlock.trim()) { @@ -92,8 +113,8 @@ function splitIntoBlocks(content: string): Block[] { // Flush remaining content if (currentBlock) { blocks.push({ - content: inCodeBlock ? currentBlock : currentBlock.trim(), - isCodeBlock: inCodeBlock // Unclosed code block = still streaming + content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(), + isCodeBlock: inCodeBlock }) } diff --git a/packages/ui/src/components/markdown/linkify.ts b/packages/ui/src/components/markdown/linkify.ts index ed06a9b7..f4ee1809 100644 --- a/packages/ui/src/components/markdown/linkify.ts +++ b/packages/ui/src/components/markdown/linkify.ts @@ -42,14 +42,34 @@ function findCodeRanges(text: string): CodeRange[] { ranges.push({ start: match.index, end: match.index + match[0].length }) } + // Find display math blocks ($$...$$) + const displayMathRegex = /\$\$[\s\S]*?\$\$/g + while ((match = displayMathRegex.exec(text)) !== null) { + const pos = match.index + const insideOther = ranges.some((r) => pos >= r.start && pos < r.end) + if (!insideOther) { + ranges.push({ start: pos, end: pos + match[0].length }) + } + } + + // Find inline math ($...$) + const inlineMathRegex = /(? pos >= r.start && pos < r.end) + if (!insideOther) { + ranges.push({ start: pos, end: pos + match[0].length }) + } + } + // Find inline code (`...`) // But skip escaped backticks and code inside fenced blocks const inlineRegex = /(? pos >= r.start && pos < r.end) - if (!insideFenced) { + // Check if this is inside a fenced block or math block + const insideOther = ranges.some((r) => pos >= r.start && pos < r.end) + if (!insideOther) { ranges.push({ start: pos, end: pos + match[0].length }) } } diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx index 4d1a043b..2936714d 100644 --- a/packages/ui/src/components/tool-call-item.tsx +++ b/packages/ui/src/components/tool-call-item.tsx @@ -11,6 +11,7 @@ import { Globe, Database, GitBranch, + BarChart3, ChevronRight, type LucideIcon, } from "lucide-react" @@ -39,6 +40,7 @@ const TOOL_DISPLAY: Record = { memory_delete: { label: "MemoryDelete", icon: Database }, memory_list: { label: "MemoryList", icon: Database }, sessions_spawn: { label: "SpawnSession", icon: GitBranch }, + data: { label: "Data", icon: BarChart3 }, } // --------------------------------------------------------------------------- @@ -73,6 +75,18 @@ function getSubtitle(toolName: string, args?: Record): string { return args.query ? String(args.query) : "" case "web_fetch": try { return new URL(String(args.url)).hostname } catch { return String(args.url ?? "") } + case "data": { + const action = String(args.action ?? "").replace(/^get_/, "") + const params = args.params as Record | undefined + const ticker = params?.ticker ? String(params.ticker).toUpperCase() : "" + return ticker ? `${action} ${ticker}` : action + } + case "sessions_spawn": { + const label = args.label ? String(args.label) : "" + if (label) return label.length > 60 ? label.slice(0, 57) + "…" : label + const task = String(args.task ?? "") + return task.length > 60 ? task.slice(0, 57) + "…" : task + } default: return "" } @@ -91,6 +105,8 @@ const RUNNING_LABELS: Record = { glob: "searching…", web_search: "searching…", web_fetch: "fetching…", + data: "fetching…", + sessions_spawn: "spawning…", } /** Stats derived from tool result content */ diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 9e3a4f4b..4faacdc9 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -250,6 +250,19 @@ color: var(--shiki-dark) !important; } +/* KaTeX math: inherit text color for light/dark theme support */ +.katex { + color: inherit; + font-size: 1em; +} + +.katex-display { + margin: 1em 0; + overflow-x: auto; + overflow-y: hidden; + padding: 0.5em 0; +} + /* Scroll fade hint utilities — mask content edges to hint at scrollable overflow */ .mask-fade-y { mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc9cc0d1..a79b09a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,13 +74,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -242,6 +242,9 @@ importers: '@multica/ui': specifier: workspace:* version: link:../../packages/ui + electron-updater: + specifier: ^6.7.3 + version: 6.7.3 lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.3) @@ -579,13 +582,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) '@mariozechner/pi-ai': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) '@mariozechner/pi-coding-agent': specifier: 'catalog:' - version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + version: 0.52.9(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) '@multica/types': specifier: workspace:* version: link:../types @@ -743,6 +746,9 @@ importers: clsx: specifier: 'catalog:' version: 2.1.1 + katex: + specifier: ^0.16.28 + version: 0.16.28 linkify-it: specifier: ^5.0.0 version: 5.0.0 @@ -764,12 +770,18 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.13)(react@19.2.3) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 rehype-raw: specifier: ^7.0.0 version: 7.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 shadcn: specifier: ^3.7.0 version: 3.8.4(@types/node@25.2.2)(typescript@5.9.3) @@ -3943,6 +3955,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -4948,6 +4963,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -5338,6 +5357,9 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-updater@6.7.3: + resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==} + electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6287,9 +6309,21 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -6305,6 +6339,9 @@ packages: hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -6862,6 +6899,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7143,6 +7184,13 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -7276,6 +7324,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -7410,6 +7461,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -8629,12 +8683,18 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -9326,6 +9386,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -9629,12 +9692,18 @@ packages: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -13976,6 +14045,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 25.2.2 @@ -15139,6 +15210,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -15538,6 +15611,19 @@ snapshots: electron-to-chromium@1.5.286: {} + electron-updater@6.7.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-vite@5.0.0(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.31.1)(terser@5.46.0)): dependencies: '@babel/core': 7.29.0 @@ -15859,9 +15945,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@2.6.1)) globals: 16.5.0 @@ -15876,8 +15962,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.6 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -15899,7 +15985,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -15910,18 +15996,18 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -15934,7 +16020,7 @@ snapshots: - supports-color - typescript - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15945,7 +16031,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -16917,6 +17003,28 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.4 @@ -16928,6 +17036,10 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -16992,6 +17104,13 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -17550,6 +17669,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + katex@0.16.28: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -17757,6 +17880,10 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + lodash.merge@4.6.2: {} lodash.throttle@4.1.1: {} @@ -17950,6 +18077,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -18292,6 +18431,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.28 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -19784,6 +19933,16 @@ snapshots: dependencies: jsesc: 3.1.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.28 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 @@ -19801,6 +19960,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -20681,6 +20849,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -20978,6 +21148,11 @@ snapshots: dependencies: crypto-random-string: 2.0.0 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -20986,6 +21161,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 diff --git a/skills/dcf-valuation/SKILL.md b/skills/dcf-valuation/SKILL.md new file mode 100644 index 00000000..5fb9b299 --- /dev/null +++ b/skills/dcf-valuation/SKILL.md @@ -0,0 +1,216 @@ +--- +name: DCF Valuation +description: Perform Discounted Cash Flow (DCF) valuation analysis for public companies. Use when the user asks to value a stock, calculate intrinsic value, fair value, perform DCF analysis, determine if a stock is undervalued or overvalued, or estimate a price target. +version: 1.1.0 +metadata: + emoji: "\U0001F9EE" + requires: + env: + - FINANCIAL_DATASETS_API_KEY + tags: + - finance + - valuation + - dcf +userInvocable: true +disableModelInvocation: false +--- + +## Instructions + +Perform a rigorous Discounted Cash Flow (DCF) valuation. Follow all steps and show your work. Use external macro context when assumptions are time-sensitive (for example, risk-free rate regime shifts). + +### Progress Checklist + +``` +DCF Analysis Progress: +- [ ] Step 1: Gather financial data +- [ ] Step 2: Calculate historical FCF and growth +- [ ] Step 3: Estimate WACC +- [ ] Step 4: Project future cash flows +- [ ] Step 5: Calculate present value and fair value +- [ ] Step 6: Sensitivity analysis +- [ ] Step 7: Validate results +- [ ] Step 8: Present findings +``` + +### Step 1: Gather Financial Data + +Use `data` tool with `domain="finance"` for all calls: + +1. **Cash Flow History** (5 years): + ``` + action: "get_cash_flow_statements" + params: { ticker: "[TICKER]", period: "annual", limit: 5 } + ``` + Extract: `free_cash_flow`, `net_cash_flow_from_operations`, `capital_expenditure` + Fallback: FCF = Operating Cash Flow - CapEx + +2. **Income Statements** (5 years): + ``` + action: "get_income_statements" + params: { ticker: "[TICKER]", period: "annual", limit: 5 } + ``` + Extract: `revenue`, `operating_income`, `net_income`, `income_tax_expense` + +3. **Balance Sheet** (latest): + ``` + action: "get_balance_sheets" + params: { ticker: "[TICKER]", period: "annual", limit: 1 } + ``` + Extract: `total_debt`, `cash_and_equivalents`, `outstanding_shares` + +4. **Financial Metrics** (current): + ``` + action: "get_financial_metrics_snapshot" + params: { ticker: "[TICKER]" } + ``` + Extract: `market_cap`, `enterprise_value`, `return_on_invested_capital`, `debt_to_equity`, `free_cash_flow_per_share` + +5. **Analyst Estimates**: + ``` + action: "get_analyst_estimates" + params: { ticker: "[TICKER]", period: "annual" } + ``` + Extract: Forward EPS estimates for growth validation + +6. **Current Price**: + ``` + action: "get_price_snapshot" + params: { ticker: "[TICKER]" } + ``` + +7. **Company Facts**: + ``` + action: "get_company_facts" + params: { ticker: "[TICKER]" } + ``` + Extract: `sector` — use to determine WACC range from [sector-wacc.md](references/sector-wacc.md) + +8. **Recent Event Context**: +- Pull company-specific headlines with: + ``` + action: "get_news" + params: { ticker: "[TICKER]", limit: 10 } + ``` +- Use this to flag event risk (guidance reset, litigation, regulation, one-off gains/losses) that may distort near-term FCF extrapolation. + +### Step 2: Calculate Historical FCF and Growth + +- Compute FCF for each of the last 5 years +- Calculate 5-year FCF CAGR: `(FCF_latest / FCF_earliest)^(1/years) - 1` +- Cross-validate with: revenue growth, operating income growth, analyst EPS growth +- **Cap projected growth at 15%** (sustained higher growth is rare) +- If FCF is volatile, weight analyst estimates more heavily + +### Step 3: Estimate WACC + +Use the company's `sector` to look up the base WACC range from [sector-wacc.md](references/sector-wacc.md). + +**Calculate WACC:** +``` +WACC = (E/V) * Re + (D/V) * Rd * (1 - Tax Rate) + +Where: + E = Market cap (equity value) + D = Total debt + V = E + D + Re = Risk-free rate + Beta * Equity Risk Premium + Rd = Cost of debt (estimate from interest expense / total debt) + Tax Rate = Effective tax rate from income statements +``` + +**Default assumptions:** +- Risk-free rate: pull latest 10-year Treasury yield using `web_search` (preferred) and cite date/source. Fallback range: ~4.0-4.5%. +- Equity risk premium: ~5.5% +- If beta unavailable, use sector average + +**Sanity check:** WACC should be 2-4% below ROIC for value-creating companies. + +### Step 4: Project Future Cash Flows (Years 1-5) + +- Apply growth rate with annual decay (multiply by 0.95 each year) +- Year 1: FCF * (1 + growth_rate) +- Year 2: FCF * (1 + growth_rate * 0.95) +- Year 3: FCF * (1 + growth_rate * 0.90) +- Year 4: FCF * (1 + growth_rate * 0.85) +- Year 5: FCF * (1 + growth_rate * 0.80) + +**Terminal Value** (Gordon Growth Model): +``` +TV = FCF_Year5 * (1 + g) / (WACC - g) +Where g = terminal growth rate (2.5% default, GDP proxy) +``` + +### Step 5: Calculate Present Value and Fair Value + +``` +PV of each FCF = FCF_t / (1 + WACC)^t +PV of Terminal Value = TV / (1 + WACC)^5 + +Enterprise Value = Sum of PV(FCFs) + PV(Terminal Value) +Net Debt = Total Debt - Cash and Equivalents +Equity Value = Enterprise Value - Net Debt +Fair Value per Share = Equity Value / Shares Outstanding +``` + +### Step 6: Sensitivity Analysis + +Create a matrix varying two key assumptions: + +| | TG 2.0% | TG 2.5% | TG 3.0% | +|---|---|---|---| +| **WACC -1%** | $ | $ | $ | +| **WACC base** | $ | $ | $ | +| **WACC +1%** | $ | $ | $ | + +(TG = Terminal Growth Rate) + +### Step 7: Validate Results + +Before presenting, check: + +1. **EV comparison**: Calculated EV within 30% of reported enterprise_value + - If off by >30%, revisit WACC or growth assumptions +2. **Terminal value ratio**: Should be 50-80% of total EV for mature companies + - If >90%, growth rate may be too high + - If <40%, near-term projections may be aggressive +3. **FCF yield check**: Compare fair value FCF yield to current market FCF yield + +If validation fails, adjust assumptions and recalculate. + +### Step 8: Present Results + +Format clearly with: + +1. **Executive Summary** + - Current price vs. fair value estimate + - Upside/downside percentage + - Verdict: Undervalued / Fairly Valued / Overvalued + +2. **Key Assumptions Table** + | Assumption | Value | Source | + |---|---|---| + | Growth Rate | X% | 5Y CAGR + analyst cross-check | + | WACC | X% | Sector range + company adjustments | + | Terminal Growth | X% | GDP proxy | + | Tax Rate | X% | Effective rate from financials | + +3. **Projected FCF Table** + | Year | FCF | Growth | PV of FCF | + |---|---|---|---| + +4. **Valuation Bridge** + - PV of projected FCFs + - PV of Terminal Value + - = Enterprise Value + - - Net Debt + - = Equity Value + - / Shares Outstanding + - = **Fair Value per Share** + +5. **Sensitivity Matrix** (from Step 6) + +6. **Risks & Caveats** + - Key risks to the valuation thesis + - DCF limitations (sensitive to growth and WACC assumptions) + - Company-specific caveats (high debt, cyclicality, early-stage, etc.) diff --git a/skills/dcf-valuation/references/sector-wacc.md b/skills/dcf-valuation/references/sector-wacc.md new file mode 100644 index 00000000..5ddc1bd1 --- /dev/null +++ b/skills/dcf-valuation/references/sector-wacc.md @@ -0,0 +1,40 @@ +# Sector WACC Reference + +Use the company's `sector` from `get_company_facts` to look up the base WACC range below, then adjust for company-specific factors. + +## WACC by Sector + +| Sector | Typical WACC Range | Notes | +|--------|-------------------|-------| +| Communication Services | 8-10% | Mix of stable telecom and growth media | +| Consumer Discretionary | 8-10% | Cyclical exposure | +| Consumer Staples | 7-8% | Defensive, stable demand | +| Energy | 9-11% | Commodity price exposure | +| Financials | 8-10% | Leverage already in business model | +| Health Care | 8-10% | Regulatory and pipeline risk | +| Industrials | 8-9% | Moderate cyclicality | +| Information Technology | 8-12% | Higher end for high-growth; lower for mature | +| Materials | 8-10% | Cyclical, commodity exposure | +| Real Estate | 7-9% | Interest rate sensitivity | +| Utilities | 6-7% | Regulated, stable cash flows | + +## Adjustment Factors + +**Add to base WACC:** +- High debt (D/E > 1.5): +1-2% +- Small cap (< $2B market cap): +1-2% +- Emerging markets exposure: +1-3% +- Concentrated customer base: +0.5-1% +- Regulatory uncertainty: +0.5-1.5% + +**Subtract from base WACC:** +- Market leader with moat: -0.5-1% +- Recurring revenue model: -0.5-1% +- Investment grade credit: -0.5% + +## Sanity Checks + +- WACC should typically be 2-4% below ROIC for value-creating companies +- If WACC > ROIC, the company may be destroying value +- Typical range for US large-cap: 7-12% +- Anything below 6% or above 14% warrants extra scrutiny diff --git a/skills/finance-research/SKILL.md b/skills/finance-research/SKILL.md new file mode 100644 index 00000000..18bee569 --- /dev/null +++ b/skills/finance-research/SKILL.md @@ -0,0 +1,174 @@ +--- +name: Finance Research +description: Conduct analyst-grade financial research across primary and secondary markets using structured financial data plus macro and public-information cross-checks. +version: 1.1.0 +metadata: + emoji: "\U0001F4CA" + requires: + env: + - FINANCIAL_DATASETS_API_KEY + tags: + - finance + - research + - stocks + - data + - macro + - sentiment +userInvocable: true +disableModelInvocation: false +--- + +## Instructions + +You are conducting financial research with an analyst-grade standard. Tool usage is a dynamic decision. Do not force tool combinations. Choose tools based on evidence sufficiency for the specific question. + +### Available Data Actions + +#### Price Data +- `get_price_snapshot` — Current stock price. Params: `{ ticker }` +- `get_prices` — Historical OHLCV prices. Params: `{ ticker, start_date, end_date, interval?, interval_multiplier? }` + - interval: "day" (default), "week", "month", "year" +- `get_crypto_price_snapshot` — Current crypto price. Params: `{ ticker }` (e.g. "BTC-USD") +- `get_crypto_prices` — Historical crypto prices. Same params as get_prices. +- `get_available_crypto_tickers` — List available crypto tickers. Params: `{}` + +#### Financial Statements +All share params: `{ ticker, period, limit?, report_period_gt?, report_period_gte?, report_period_lt?, report_period_lte? }` +- period: "annual", "quarterly", or "ttm" +- Dates in YYYY-MM-DD format + +Actions: +- `get_income_statements` — Revenue, expenses, net income, EPS +- `get_balance_sheets` — Assets, liabilities, equity, debt, cash +- `get_cash_flow_statements` — Operating, investing, financing cash flows, FCF +- `get_all_financial_statements` — All three at once (more efficient when you need multiple) + +#### Metrics & Estimates +- `get_financial_metrics_snapshot` — Current key ratios (P/E, market cap, margins, etc.). Params: `{ ticker }` +- `get_financial_metrics` — Historical metrics. Params: `{ ticker, period?, limit?, report_period*? }` +- `get_analyst_estimates` — EPS and revenue estimates. Params: `{ ticker, period? }` + +#### Company Info +- `get_company_facts` — Sector, industry, employees, exchange, website. Params: `{ ticker }` +- `get_news` — Recent company news articles. Params: `{ ticker, start_date?, end_date?, limit? }` +- `get_insider_trades` — Insider buying/selling (SEC Form 4). Params: `{ ticker, limit?, filing_date*? }` +- `get_segmented_revenues` — Revenue by segment/geography. Params: `{ ticker, period, limit? }` + +#### SEC Filings +- `get_filings` — List filings metadata. Params: `{ ticker, filing_type?, limit? }` +- `get_filing_items` — Read filing sections. Params: `{ ticker, filing_type, accession_number?, item? }` + +### Evidence Sufficiency Gate (Internal Decision) + +Before deep analysis, make an internal evidence decision. Do not output a technical decision block by default. + +If the user explicitly asks for methodology or reasoning transparency, provide a concise plain-language explanation of your research approach. + +Decision policy: + +- Start with `data_only` when structured data can support the requested conclusion. +- Escalate to `hybrid` when the task is event-driven, time-sensitive, or requires causal explanation not visible in structured data alone. +- Use `web_first` only when the task is mainly document/news/policy driven (common in pre-IPO without stable ticker coverage). +- If a tool is unavailable, continue with available tools and explicitly downgrade confidence. + +### Core Analysis Framework + +1. **Scope & Market Type** +- Identify if this is primary market (IPO, pre-IPO, follow-on, placement) or secondary market (listed stock/sector/index). +- State region and analysis horizon (event-driven, 3-6 months, 1-3 years). + +2. **Core Company Data (Structured)** +- Start with: `get_price_snapshot`, `get_company_facts`, `get_financial_metrics_snapshot`. +- Pull statements (`get_all_financial_statements`) and estimates as needed. + +3. **Macro & Policy Context (Conditional)** +- Use `web_search` / `web_fetch` only if required by your internal evidence decision. +- If used, prefer high-signal primary sources (central bank, regulator, official releases). +- For time-sensitive conclusions, include source dates explicitly. + +4. **News & Sentiment Context (Conditional)** +- Use `get_news` for company-linked coverage when available. +- Add web cross-checks only when event validation materially affects the conclusion. + +5. **Synthesis & Decision** +- Separate **facts**, **inference**, and **assumptions**. +- Build bull/base/bear scenarios with explicit trigger conditions. +- Provide confidence level and explain the main uncertainty drivers. + +### Primary Market (一级市场) Workflow + +When asked about IPOs, pre-IPO, or new issuance: + +1. **Deal Basics** +- Identify issuer, listing venue, offering structure (primary/secondary shares), expected timeline. +- Determine whether a reliable ticker exists in current data coverage. + +2. **Filing/Prospectus Review** +- Prefer official documents (e.g., S-1/F-1/prospectus) via `web_search` + `web_fetch`. +- Extract: use of proceeds, customer concentration, related-party transactions, share classes, lock-up, dilution risks. + +Primary-market capability boundary: +- If `ticker` is available and filings are retrievable, run hybrid analysis (structured + document evidence). +- If `ticker` is unavailable or structured filing fields are limited, run web-led analysis and clearly label it as partial-coverage with reduced confidence. + +3. **Valuation & Comparable Set** +- Build peer set from listed comps (secondary market tickers) and compare growth, margin, and valuation multiples. +- Flag gaps between issuer narrative and peer reality. + +4. **Deal Risk Map** +- Highlight red flags: weak FCF quality, aggressive non-GAAP adjustments, concentrated revenue, regulatory overhang. +- Provide post-listing watch items: lock-up expiry, first earnings, guidance revisions. + +### Secondary Market (二级市场) Workflow + +When asked about listed equities: + +1. **Trend & Positioning** +- Pull 1y price history (`get_prices`) and identify regime (uptrend/range/downtrend) with volatility context. + +2. **Fundamentals** +- Analyze growth quality (revenue vs FCF), margin durability, leverage, and capital allocation. + +3. **Valuation** +- Compare current multiples to historical bands and peers (when peer data is available). +- Connect valuation premium/discount to expected growth and risk profile. + +4. **Catalysts & Risks** +- Earnings, guidance, product cycle, policy changes, rates/FX/commodity sensitivity, insider activity. + +### Output Standard + +Always include: + +1. **Executive Summary** (thesis + stance + confidence) +2. **Evidence Table** with columns: +- Signal +- Direction (Bull/Bear/Neutral) +- Why it matters +- Source +- Date +3. **Scenario Table** (bull/base/bear with probabilities or relative weights) +4. **Key Monitoring Triggers** (what would invalidate current thesis) + +### Guardrails + +- Always state data cutoff dates. +- If data is missing, explicitly mark it and show the impact on confidence. +- Do not present assumptions as facts. +- For event-driven conclusions, if you skip web validation, explicitly explain why structured evidence is still sufficient. + + +### Example: Secondary Market Analysis + +For "Analyze Apple's investment outlook": + +1. `data(domain="finance", action="get_price_snapshot", params={ticker: "AAPL"})` +2. `data(domain="finance", action="get_company_facts", params={ticker: "AAPL"})` +3. `data(domain="finance", action="get_all_financial_statements", params={ticker: "AAPL", period: "annual", limit: 3})` +4. `data(domain="finance", action="get_financial_metrics", params={ticker: "AAPL", period: "quarterly", limit: 8})` +5. `data(domain="finance", action="get_analyst_estimates", params={ticker: "AAPL", period: "annual"})` +6. `data(domain="finance", action="get_news", params={ticker: "AAPL", limit: 10})` +7. `web_search(query="latest Fed policy decision impact on US mega-cap tech valuations")` +8. `web_search(query="Apple supply chain or regulatory news latest quarter")` + +Then synthesize fundamental trend, macro regime, and event sentiment into a scenario-based conclusion.