merge: integrate origin/main into feat/onboarding-check
Resolved conflicts: - Keep Lucide icons (replaced Hugeicons) in desktop and ui - Keep new Sidebar layout design - Merge new dependencies (electron-updater, lucide-react, katex) - Add new 'data' tool with Lucide BarChart3 icon - Keep UpdateNotification component (not integrated into UI yet) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
b310b57ce9
45 changed files with 2890 additions and 130 deletions
3
apps/desktop/dev-app-update.yml
Normal file
3
apps/desktop/dev-app-update.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
provider: github
|
||||
owner: multica-ai
|
||||
repo: multica
|
||||
|
|
@ -63,5 +63,10 @@
|
|||
"category": "Utility",
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}"
|
||||
},
|
||||
"npmRebuild": false
|
||||
"npmRebuild": false,
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "multica-ai",
|
||||
"repo": "multica"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
124
apps/desktop/src/main/updater/index.ts
Normal file
124
apps/desktop/src/main/updater/index.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<typeof callback>[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 */
|
||||
|
|
|
|||
144
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
144
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
|
|
@ -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<UpdateStatus | null>(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<void> => {
|
||||
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 (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-lg">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${isError ? 'bg-destructive/10' : 'bg-primary/10'}`}
|
||||
>
|
||||
{isError ? (
|
||||
<HugeiconsIcon icon={AlertCircleIcon} className="h-4 w-4 text-destructive" />
|
||||
) : updateStatus.status === 'downloaded' ? (
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-4 w-4 text-primary" />
|
||||
) : updateStatus.status === 'downloading' ? (
|
||||
<HugeiconsIcon icon={Loading03Icon} className="h-4 w-4 text-primary animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Download04Icon} className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{isError
|
||||
? 'Update failed'
|
||||
: updateStatus.status === 'downloaded'
|
||||
? 'Update ready'
|
||||
: updateStatus.status === 'downloading'
|
||||
? 'Downloading update...'
|
||||
: 'Update available'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isError
|
||||
? 'Please download manually from GitHub'
|
||||
: updateStatus.status === 'downloading' && updateStatus.progress
|
||||
? `${Math.round(updateStatus.progress.percent)}%`
|
||||
: version
|
||||
? `Version ${version}`
|
||||
: 'New version available'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{updateStatus.status === 'available' && (
|
||||
<Button size="sm" variant="default" onClick={handleDownload}>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus.status === 'downloaded' && (
|
||||
<Button size="sm" variant="default" onClick={handleInstall}>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{isError && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open('https://github.com/multica-ai/multica/releases', '_blank')
|
||||
}
|
||||
>
|
||||
View Releases
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus.status !== 'downloading' && (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleDismiss}>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<ChannelItem> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export class Agent {
|
|||
|
||||
// Internal run state
|
||||
private _internalRun = false;
|
||||
private _isRunning = false;
|
||||
private _runMutex: Promise<void> = 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);
|
||||
|
|
|
|||
172
packages/core/src/agent/subagent/README.md
Normal file
172
packages/core/src/agent/subagent/README.md
Normal file
|
|
@ -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
|
||||
203
packages/core/src/agent/subagent/announce-queue.test.ts
Normal file
203
packages/core/src/agent/subagent/announce-queue.test.ts
Normal file
|
|
@ -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>): 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);
|
||||
});
|
||||
});
|
||||
315
packages/core/src/agent/subagent/announce-queue.ts
Normal file
315
packages/core/src/agent/subagent/announce-queue.ts
Normal file
|
|
@ -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<void>;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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<string, AnnounceQueueState>();
|
||||
|
||||
// ============================================================================
|
||||
// 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<void>;
|
||||
}): 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<void>,
|
||||
): 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<void> {
|
||||
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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const CORE_TOOL_SUMMARIES: Record<string, string> = {
|
|||
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.",
|
||||
|
|
|
|||
39
packages/core/src/agent/tokens.test.ts
Normal file
39
packages/core/src/agent/tokens.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
17
packages/core/src/agent/tokens.ts
Normal file
17
packages/core/src/agent/tokens.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<any>[] = [
|
||||
...baseTools,
|
||||
|
|
@ -119,6 +123,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
webFetchTool as AgentTool<any>,
|
||||
webSearchTool as AgentTool<any>,
|
||||
cronTool as AgentTool<any>,
|
||||
dataTool as AgentTool<any>,
|
||||
];
|
||||
|
||||
// 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<any>);
|
||||
|
||||
|
|
@ -165,6 +171,7 @@ export function resolveTools(options: ResolveToolsOptions): AgentTool<any>[] {
|
|||
profileDir: options.profileDir,
|
||||
isSubagent: options.isSubagent,
|
||||
sessionId: options.sessionId,
|
||||
provider: options.provider,
|
||||
onExecApprovalNeeded: options.onExecApprovalNeeded,
|
||||
});
|
||||
|
||||
|
|
|
|||
118
packages/core/src/agent/tools/data/data-tool.ts
Normal file
118
packages/core/src/agent/tools/data/data-tool.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export type DataToolResult = {
|
||||
domain: string;
|
||||
action: string;
|
||||
data: unknown;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
|
||||
// ─── Factory ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createDataTool(): AgentTool<typeof DataToolSchema, DataToolResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
295
packages/core/src/agent/tools/data/finance/actions.ts
Normal file
295
packages/core/src/agent/tools/data/finance/actions.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
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<FinanceActionResult>;
|
||||
|
||||
const handlers: Record<FinanceAction, ActionHandler> = {
|
||||
// ── 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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FinanceActionResult> {
|
||||
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);
|
||||
}
|
||||
79
packages/core/src/agent/tools/data/finance/api.ts
Normal file
79
packages/core/src/agent/tools/data/finance/api.ts
Normal file
|
|
@ -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<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
params: Record<string, string | string[] | number | boolean | undefined>,
|
||||
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() };
|
||||
}
|
||||
35
packages/core/src/agent/tools/data/finance/types.ts
Normal file
35
packages/core/src/agent/tools/data/finance/types.ts
Normal file
|
|
@ -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<string>(FINANCE_ACTIONS);
|
||||
1
packages/core/src/agent/tools/data/index.ts
Normal file
1
packages/core/src/agent/tools/data/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createDataTool, type DataToolResult } from "./data-tool.js";
|
||||
|
|
@ -39,6 +39,9 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||
// 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<string, string[]> = {
|
|||
"process",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"data",
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ type StubAgent = {
|
|||
closed: boolean;
|
||||
sessionId: string;
|
||||
ensureInitialized: () => Promise<void>;
|
||||
getMessages: () => Array<any>;
|
||||
write: (content: string, options?: { injectTimestamp?: boolean }) => void;
|
||||
waitForIdle: () => Promise<void>;
|
||||
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<any> = [];
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<HeartbeatEventPayload, "ts"> = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={cn('markdown-content break-words', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
components={components}
|
||||
>
|
||||
{processedContent}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = /(?<!\$)\$(?!\$)([^\$\n]+)\$(?!\$)/g
|
||||
while ((match = inlineMathRegex.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 code (`...`)
|
||||
// But skip escaped backticks and code inside fenced blocks
|
||||
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
|
||||
while ((match = inlineRegex.exec(text)) !== null) {
|
||||
const pos = match.index
|
||||
// Check if this is inside a fenced block
|
||||
const insideFenced = ranges.some((r) => 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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Globe,
|
||||
Database,
|
||||
GitBranch,
|
||||
BarChart3,
|
||||
ChevronRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
|
@ -39,6 +40,7 @@ const TOOL_DISPLAY: Record<string, { label: string; icon: LucideIcon }> = {
|
|||
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, unknown>): 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<string, unknown> | 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<string, string> = {
|
|||
glob: "searching…",
|
||||
web_search: "searching…",
|
||||
web_fetch: "fetching…",
|
||||
data: "fetching…",
|
||||
sessions_spawn: "spawning…",
|
||||
}
|
||||
|
||||
/** Stats derived from tool result content */
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
212
pnpm-lock.yaml
generated
212
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
216
skills/dcf-valuation/SKILL.md
Normal file
216
skills/dcf-valuation/SKILL.md
Normal file
|
|
@ -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.)
|
||||
40
skills/dcf-valuation/references/sector-wacc.md
Normal file
40
skills/dcf-valuation/references/sector-wacc.md
Normal file
|
|
@ -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
|
||||
174
skills/finance-research/SKILL.md
Normal file
174
skills/finance-research/SKILL.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue