Migrate to Pi Coding Agent SDK for multi-provider LLM support
Replace the custom Pi API fetch client with the @earendil-works/pi-coding-agent SDK, enabling support for multiple LLM providers (Anthropic, OpenAI, DeepSeek, Google Gemini, xAI/ZAI) while maintaining backward compatibility. Key changes: - Add piSdk.ts with SDK session management and provider auto-detection - Refactor client.ts to delegate to SDK adapter, keeping public API surface - Update documentation to reflect multi-provider environment variables - Add RPC contracts for LLM model selection and provider configuration - Update agent runner to support provider-specific tools and parameters Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,10 +25,12 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.74.0",
|
||||
"@mosaiciq/contracts": "workspace:*",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
"pptxgenjs": "^4.0.1",
|
||||
"typebox": "^1.1.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13"
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Individual agent execution
|
||||
* Runs a single agent with proper context and error handling
|
||||
*
|
||||
* Now uses the Pi Coding Agent SDK for LLM interaction with custom tools
|
||||
* instead of the old "build context → embed in prompt" pattern.
|
||||
*/
|
||||
|
||||
import type { Db } from "../db/database.js";
|
||||
import { streamResponse, streamStructuredResponse, isConfigured } from "../llm/client.js";
|
||||
import { getAgentPrompt, getAgentMetadata } from "../llm/prompts.js";
|
||||
import { buildAgentContext } from "../llm/context.js";
|
||||
import { createSession, registerSession, unregisterSession, isConfigured as sdkIsConfigured } from "../llm/piSdk.js";
|
||||
import { getAgentMetadata } from "../llm/prompts.js";
|
||||
import { createAllTools } from "../tools/index.js";
|
||||
import {
|
||||
emitAgentProgress,
|
||||
emitAgentStarted,
|
||||
@@ -14,13 +17,21 @@ import {
|
||||
emitAgentFailed,
|
||||
emitAgentStreaming,
|
||||
} from "./eventEmitter.js";
|
||||
import {
|
||||
addAgentRunStep,
|
||||
createAgentRun,
|
||||
getServerSettings,
|
||||
listAgentRunSteps,
|
||||
pauseAgent,
|
||||
storeAgentOutput,
|
||||
updateAgentRunCompletion,
|
||||
} from "../db/queries.js";
|
||||
|
||||
export interface AgentRunOptions {
|
||||
runId?: string;
|
||||
onProgress?: (progress: number, action: string) => void;
|
||||
signal?: AbortSignal;
|
||||
structured?: boolean;
|
||||
schema?: Record<string, unknown>;
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
export interface AgentRunResult {
|
||||
@@ -36,31 +47,185 @@ export interface AgentRunResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single agent
|
||||
* Agent system prompts — instruction-only, no data embedded.
|
||||
* The LLM fetches data via custom tools.
|
||||
*/
|
||||
const AGENT_SYSTEM_PROMPTS: Record<string, string> = {
|
||||
sf: `You are the SEC Filings Agent for MosaicIQ equity research.
|
||||
Your role: Analyze SEC filings and extract structured financial information.
|
||||
|
||||
Use the available tools to gather data:
|
||||
- get_company_info: Get company details
|
||||
- get_filings: List SEC filings (10-K, 10-Q, 8-K, etc.)
|
||||
|
||||
Workflow:
|
||||
1. Use get_company_info to identify the company
|
||||
2. Use get_filings to retrieve recent filings
|
||||
3. Analyze the filings for segment revenue data, risk factors, accounting policy changes
|
||||
4. Cite specific sections when making claims
|
||||
|
||||
Output JSON with:
|
||||
- extractedData: Key findings from filings
|
||||
- sources: List of filings and sections referenced
|
||||
- assumptions: Any assumptions made during analysis
|
||||
- confidence: "high" | "medium" | "low"
|
||||
|
||||
Rules: Always cite sources. Flag uncertainty. Never fabricate filing content.`,
|
||||
|
||||
cr: `You are the Company Research Agent for MosaicIQ equity research.
|
||||
Analyze the company's business model and competitive position.
|
||||
|
||||
Use tools to gather data about the company, filings, earnings, catalysts, and risks.
|
||||
Provide a structured analysis covering:
|
||||
- businessModel: How the company makes money
|
||||
- competitiveAdvantages: Key advantages
|
||||
- marketPosition: Assessment of market position
|
||||
- recentDevelopments: Important recent changes
|
||||
- sources: Citations for your claims`,
|
||||
|
||||
fm: `You are the Financial Modeling Agent for MosaicIQ equity research.
|
||||
Build and analyze financial projections for the company.
|
||||
|
||||
Use tools to get the financial model, company info, filings, and earnings data.
|
||||
1. Review historical data from the model
|
||||
2. Project revenue for the next 3 years
|
||||
3. Model key margin drivers
|
||||
4. Document assumptions clearly
|
||||
5. Flag sensitive assumptions
|
||||
|
||||
If the model needs updates, use update_model_cell or create_model_row tools.
|
||||
|
||||
Output JSON with: projections, keyAssumptions, sensitivityAnalysis, confidence.`,
|
||||
|
||||
va: `You are the Valuation Agent for MosaicIQ equity research.
|
||||
Provide a comprehensive valuation analysis.
|
||||
|
||||
Use tools to get the memo, financial model, and company data.
|
||||
1. Apply appropriate valuation methodologies (DCF, multiples, etc.)
|
||||
2. Provide a fair value estimate with range
|
||||
3. Identify key value drivers
|
||||
4. Discuss upside/downside scenarios
|
||||
|
||||
Output JSON with: methodologies, fairValue, keyDrivers, scenarios, assumptions.`,
|
||||
|
||||
mw: `You are the Memo Writing Agent for MosaicIQ equity research.
|
||||
Synthesize research into a clear investment memo.
|
||||
|
||||
Use tools to get the current memo, company data, filings, and model.
|
||||
Review and refine the thesis statement. Ensure coherence across sections.
|
||||
If improvements are needed, use update_memo_section to revise content.
|
||||
|
||||
Output JSON with: refinedSections, suggestions, consistencyCheck, overallQuality.`,
|
||||
|
||||
pa: `You are the Presentation Agent for MosaicIQ equity research.
|
||||
Create investment committee presentation materials.
|
||||
|
||||
Use tools to get the memo and company data.
|
||||
Structure presentation for investment committee. Create slide outlines.
|
||||
|
||||
Output JSON with: slideOutline, keyCharts, talkingPoints, timing.`,
|
||||
|
||||
ec: `You are the Earnings Call Agent for MosaicIQ equity research.
|
||||
Analyze earnings call data for insights.
|
||||
|
||||
Use tools to get earnings schedule, filings, and company data.
|
||||
Extract management guidance, tone changes, and key Q&A insights.
|
||||
|
||||
Output JSON with: guidance, keyInsights, toneAnalysis, qAHighlights, actionItems.`,
|
||||
|
||||
ci: `You are the Competitive Intelligence Agent for MosaicIQ equity research.
|
||||
Analyze competitive positioning and peer dynamics.
|
||||
|
||||
Use tools to get company data, risks, and alerts.
|
||||
Identify key competitors, assess market share trends.
|
||||
|
||||
Output JSON with: competitors, marketPosition, recentMoves, threats, opportunities.`,
|
||||
|
||||
rk: `You are the Risk Assessment Agent for MosaicIQ equity research.
|
||||
Identify and analyze key investment risks.
|
||||
|
||||
Use tools to get the memo, filings, risks, and alerts.
|
||||
Categorize risks by type, assess likelihood and impact.
|
||||
|
||||
Output JSON with: risks, categories, highPriorityRisks, mitigationStrategies.`,
|
||||
|
||||
rt: `You are the Red Team Agent for MosaicIQ equity research.
|
||||
Challenge the investment thesis and identify weaknesses.
|
||||
|
||||
Use tools to get the memo (especially thesis section), model, risks, and alerts.
|
||||
1. Identify weaknesses in the thesis
|
||||
2. Uncover unstated assumptions
|
||||
3. Develop bear case scenarios
|
||||
4. Challenge key projections
|
||||
|
||||
Output JSON with: counterArguments, unstatedAssumptions, bearCase, redFlags, recommendation.`,
|
||||
|
||||
mn: `You are the Monitoring Agent for MosaicIQ equity research.
|
||||
Set up ongoing monitoring of key indicators for the company.
|
||||
|
||||
Use tools to get company data, catalysts, risks, and alerts.
|
||||
Identify key metrics to monitor with thresholds.
|
||||
|
||||
Output JSON with: metricsToMonitor, alertConditions, dataSources, monitoringCadence.`,
|
||||
|
||||
sv: `You are the Source Verification Agent for MosaicIQ equity research.
|
||||
Verify the accuracy of citations and sources in the memo.
|
||||
|
||||
Use tools to get the memo, filings, and validation status.
|
||||
Check that claims are properly cited. Flag uncited assertions.
|
||||
|
||||
Output JSON with: verificationResults, flaggedClaims, sourceQuality, recommendations.`,
|
||||
|
||||
ex: `You are the Export Agent for MosaicIQ equity research.
|
||||
Prepare data for export in various formats.
|
||||
|
||||
Use tools to get memo, model, and company data.
|
||||
If writing Excel files, use the write_excel tool.
|
||||
|
||||
Output JSON with: pdfReady, excelReady, pptReady, metadata.`,
|
||||
|
||||
qa: `You are the Model QA Agent for MosaicIQ equity research.
|
||||
Audit the financial model for errors and inconsistencies.
|
||||
|
||||
Use tools to get the financial model, company data, and filings.
|
||||
Check for formula errors, verify consistency, run sanity checks.
|
||||
|
||||
Output JSON with: formulaErrors, consistencyIssues, sanityCheckResults, recommendations, overallStatus.`,
|
||||
|
||||
chat: `You are a research assistant for MosaicIQ equity research.
|
||||
Help the analyst with questions about the company.
|
||||
|
||||
Use the available tools to look up company data, filings, model, memo, earnings, etc.
|
||||
Answer questions based on available data. Cite sources when possible.
|
||||
Admit when you don't have enough information.
|
||||
Respond in a helpful, professional tone.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the system prompt for an agent
|
||||
*/
|
||||
function getAgentSystemPrompt(agentId: string): string {
|
||||
return AGENT_SYSTEM_PROMPTS[agentId] ?? AGENT_SYSTEM_PROMPTS.chat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single agent using the Pi SDK
|
||||
*/
|
||||
export async function executeAgent(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
options: AgentRunOptions = {}
|
||||
options: AgentRunOptions = {},
|
||||
): Promise<AgentRunResult> {
|
||||
const {
|
||||
onProgress,
|
||||
signal,
|
||||
structured = false,
|
||||
schema,
|
||||
} = options;
|
||||
|
||||
const runId = options.runId ?? `run-${Date.now()}`;
|
||||
const { onProgress, userMessage } = options;
|
||||
const runId = options.runId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
// Check if LLM is configured
|
||||
if (!isConfigured()) {
|
||||
const errorMsg = "LLM API key is not configured. Please set PI_API_KEY.";
|
||||
if (!sdkIsConfigured()) {
|
||||
const errorMsg = "LLM API key is not configured. Set ANTHROPIC_API_KEY or another supported provider key.";
|
||||
console.error(`[Agent] ${errorMsg}`);
|
||||
|
||||
// Update agent run status in database
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "failed", errorMsg);
|
||||
updateAgentRunCompletion(db, runId, "failed", "Configuration error", errorMsg);
|
||||
|
||||
return {
|
||||
runId,
|
||||
@@ -76,61 +241,88 @@ export async function executeAgent(
|
||||
}
|
||||
|
||||
try {
|
||||
// Update agent run status to running
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "running");
|
||||
|
||||
// Get agent metadata
|
||||
const metadata = getAgentMetadata(agentId);
|
||||
const action = metadata?.description || "Running agent";
|
||||
|
||||
// Update agent run status to running
|
||||
updateAgentRunCompletion(db, runId, "running", action);
|
||||
|
||||
// Emit agent started event
|
||||
emitAgentStarted(runId, agentId, companyId);
|
||||
|
||||
onProgress?.(0, action);
|
||||
emitAgentProgress(runId, agentId, companyId, 0, action);
|
||||
|
||||
// Build agent context
|
||||
const context = await buildAgentContext(db, agentId, companyId, {
|
||||
includeHistoricalData: true,
|
||||
includeModel: true,
|
||||
// Create custom tools with DB access for this agent
|
||||
const tools = createAllTools(db);
|
||||
const llmSettings = getServerSettings(db).llm;
|
||||
|
||||
// Create SDK session with tools
|
||||
const session = await createSession({
|
||||
systemPrompt: getAgentSystemPrompt(agentId),
|
||||
modelSelection: llmSettings?.provider && llmSettings.modelId
|
||||
? {
|
||||
provider: llmSettings.provider,
|
||||
modelId: llmSettings.modelId,
|
||||
thinkingLevel: llmSettings.thinkingLevel,
|
||||
}
|
||||
: undefined,
|
||||
customTools: tools,
|
||||
onEvent: (event) => {
|
||||
// Bridge SDK events to our EventEmitter
|
||||
switch (event.type) {
|
||||
case "message_update":
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
emitAgentStreaming(runId, agentId, companyId, event.assistantMessageEvent.delta);
|
||||
}
|
||||
break;
|
||||
case "tool_execution_start":
|
||||
emitAgentProgress(runId, agentId, companyId, 50, `Running tool: ${event.toolName}`);
|
||||
onProgress?.(50, `Running tool: ${event.toolName}`);
|
||||
break;
|
||||
case "tool_execution_end":
|
||||
emitAgentProgress(runId, agentId, companyId, 70, `Tool complete: ${event.toolName}`);
|
||||
break;
|
||||
case "agent_start":
|
||||
emitAgentProgress(runId, agentId, companyId, 20, `Executing ${action}`);
|
||||
onProgress?.(20, `Executing ${action}`);
|
||||
break;
|
||||
case "agent_end":
|
||||
emitAgentProgress(runId, agentId, companyId, 90, `Finalizing ${action}`);
|
||||
onProgress?.(90, `Finalizing ${action}`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onProgress?.(20, `Gathering data for ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, 20, `Gathering data for ${action}`);
|
||||
// Register for abort/cancellation support
|
||||
registerSession(runId, session);
|
||||
|
||||
// Get agent prompt
|
||||
const prompt = getAgentPrompt(agentId, context);
|
||||
// Build the prompt — focused, not data-heavy
|
||||
const prompt = userMessage
|
||||
? `Analyze company "${companyId}". ${userMessage}`
|
||||
: `Analyze company "${companyId}" using your available tools. Provide a comprehensive analysis with structured output.`;
|
||||
|
||||
onProgress?.(40, `Executing ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, 40, `Executing ${action}`);
|
||||
// Send prompt and wait for completion
|
||||
await session.prompt(prompt);
|
||||
|
||||
// Extract the response
|
||||
const messages = session.agent.state.messages;
|
||||
const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
|
||||
|
||||
// Execute LLM call
|
||||
let rawResponse = "";
|
||||
let output: unknown;
|
||||
|
||||
if (structured && schema) {
|
||||
output = await streamStructuredResponse(prompt, schema, {
|
||||
onProgress: (text) => {
|
||||
rawResponse += text;
|
||||
// Emit streaming event for live output
|
||||
emitAgentStreaming(runId, agentId, companyId, text);
|
||||
const progress = 40 + Math.min(40, (rawResponse.length / 4000) * 40);
|
||||
onProgress?.(progress, `Processing ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, progress, `Processing ${action}`);
|
||||
},
|
||||
signal,
|
||||
});
|
||||
} else {
|
||||
rawResponse = await streamResponse(prompt, {
|
||||
onProgress: (text) => {
|
||||
// Emit streaming event for live output
|
||||
emitAgentStreaming(runId, agentId, companyId, text);
|
||||
const progress = 40 + Math.min(40, (rawResponse.length / 4000) * 40);
|
||||
onProgress?.(progress, `Processing ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, progress, `Processing ${action}`);
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (lastAssistant && lastAssistant.content) {
|
||||
// Extract text from content blocks
|
||||
if (typeof lastAssistant.content === "string") {
|
||||
rawResponse = lastAssistant.content;
|
||||
} else if (Array.isArray(lastAssistant.content)) {
|
||||
for (const block of lastAssistant.content) {
|
||||
if (block.type === "text") {
|
||||
rawResponse += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
@@ -140,24 +332,20 @@ export async function executeAgent(
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(90, `Finalizing ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, 90, `Finalizing ${action}`);
|
||||
|
||||
// Store agent output in database
|
||||
// Store agent output
|
||||
storeAgentOutput(db, runId, agentId, companyId, output, rawResponse);
|
||||
updateAgentRunCompletion(db, runId, "completed", `Completed ${action}`);
|
||||
|
||||
// Update agent run status to completed
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "completed");
|
||||
|
||||
onProgress?.(100, `Completed ${action}`);
|
||||
emitAgentProgress(runId, agentId, companyId, 100, `Completed ${action}`);
|
||||
onProgress?.(100, `Completed ${action}`);
|
||||
|
||||
// Calculate duration
|
||||
const duration = Date.now() - new Date(startedAt).getTime();
|
||||
|
||||
// Emit completion event
|
||||
emitAgentCompleted(runId, agentId, companyId, output, duration);
|
||||
|
||||
// Unregister session
|
||||
unregisterSession(runId);
|
||||
session.dispose();
|
||||
|
||||
return {
|
||||
runId,
|
||||
agentId,
|
||||
@@ -172,9 +360,11 @@ export async function executeAgent(
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check if cancelled
|
||||
if (signal?.aborted) {
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "cancelled");
|
||||
if (options.signal?.aborted) {
|
||||
updateAgentRunCompletion(db, runId, "cancelled", "Cancelled by user");
|
||||
emitAgentFailed(runId, agentId, companyId, "Cancelled by user");
|
||||
unregisterSession(runId);
|
||||
|
||||
return {
|
||||
runId,
|
||||
agentId,
|
||||
@@ -188,11 +378,9 @@ export async function executeAgent(
|
||||
};
|
||||
}
|
||||
|
||||
// Update agent run status to failed
|
||||
updateAgentRunStatus(db, runId, agentId, companyId, "failed", errorMsg);
|
||||
|
||||
// Emit failed event
|
||||
updateAgentRunCompletion(db, runId, "failed", "Agent failed", errorMsg);
|
||||
emitAgentFailed(runId, agentId, companyId, errorMsg);
|
||||
unregisterSession(runId);
|
||||
|
||||
return {
|
||||
runId,
|
||||
@@ -208,59 +396,6 @@ export async function executeAgent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent run status in database
|
||||
*/
|
||||
function updateAgentRunStatus(
|
||||
db: Db,
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
status: "running" | "completed" | "failed" | "cancelled",
|
||||
error?: string
|
||||
): void {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO agent_runs (id, agent_id, company_id, status, started_at${error ? ", error" : ""})
|
||||
VALUES (?, ?, ?, ?, datetime('now')${error ? ", ?" : ""})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
completed_at = CASE WHEN excluded.status IN ('completed', 'failed', 'cancelled') THEN datetime('now') ELSE completed_at END,
|
||||
error = COALESCE(excluded.error, error)
|
||||
`);
|
||||
|
||||
if (error) {
|
||||
stmt.run(runId, agentId, companyId, status, error);
|
||||
} else {
|
||||
stmt.run(runId, agentId, companyId, status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store agent output in database
|
||||
*/
|
||||
function storeAgentOutput(
|
||||
db: Db,
|
||||
runId: string,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
output: unknown,
|
||||
rawResponse: string
|
||||
): void {
|
||||
// Store output as JSON in the action field for now
|
||||
// In a production system, you'd have a separate table for agent outputs
|
||||
const stmt = db.prepare(`
|
||||
UPDATE agent_runs
|
||||
SET action = ?, progress = 100
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(JSON.stringify({ output, rawResponse: rawResponse.slice(0, 1000) }), runId);
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO agent_outputs (run_id, agent_id, company_id, output_json, raw_response)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(runId, agentId, companyId, JSON.stringify(output), rawResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute agent with simple callback interface
|
||||
*/
|
||||
@@ -268,7 +403,7 @@ export async function executeAgentSimple(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
onProgress?: (message: string) => void
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<unknown> {
|
||||
const result = await executeAgent(db, agentId, companyId, {
|
||||
onProgress: (progress, action) => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { Db } from "../db/database.js";
|
||||
import { streamStructuredResponse, completeStructured, isConfigured } from "../llm/client.js";
|
||||
import { complete, isConfigured } from "../llm/client.js";
|
||||
import { getAgentPrompt, type AgentContext } from "../llm/prompts.js";
|
||||
import { buildAgentContext } from "../llm/context.js";
|
||||
import { emitValidationUpdated } from "./eventEmitter.js";
|
||||
@@ -69,7 +69,14 @@ export async function executeSourceVerification(
|
||||
recommendations: ["string"],
|
||||
};
|
||||
|
||||
const result = await completeStructured(prompt, schema);
|
||||
const rawResult = await complete(prompt);
|
||||
let result: any;
|
||||
try {
|
||||
result = JSON.parse(rawResult);
|
||||
} catch {
|
||||
const jsonMatch = rawResult.match(/\{[\s\S]*\}/);
|
||||
result = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
|
||||
}
|
||||
|
||||
// Extract issues from the result
|
||||
const issues: ValidationResult["issues"] = [];
|
||||
@@ -172,7 +179,14 @@ export async function executeModelQA(
|
||||
overallStatus: ["pass", "warn", "fail"],
|
||||
};
|
||||
|
||||
const result = await completeStructured(prompt, schema);
|
||||
const rawResult = await complete(prompt);
|
||||
let result: any;
|
||||
try {
|
||||
result = JSON.parse(rawResult);
|
||||
} catch {
|
||||
const jsonMatch = rawResult.match(/\{[\s\S]*\}/);
|
||||
result = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
|
||||
}
|
||||
|
||||
// Extract issues from the result
|
||||
const issues: ValidationResult["issues"] = [];
|
||||
@@ -288,7 +302,14 @@ export async function executeRedTeam(
|
||||
recommendation: ["proceed_with_caution", "acceptable", "needs_revision"],
|
||||
};
|
||||
|
||||
const result = await completeStructured(prompt, schema);
|
||||
const rawResult = await complete(prompt);
|
||||
let result: any;
|
||||
try {
|
||||
result = JSON.parse(rawResult);
|
||||
} catch {
|
||||
const jsonMatch = rawResult.match(/\{[\s\S]*\}/);
|
||||
result = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
|
||||
}
|
||||
|
||||
// Extract issues from the result
|
||||
const issues: ValidationResult["issues"] = [];
|
||||
|
||||
@@ -830,7 +830,7 @@ export function storeAgentOutput(db: Db, runId: string, agentId: string, company
|
||||
export function updateAgentRunCompletion(
|
||||
db: Db,
|
||||
runId: string,
|
||||
status: "completed" | "failed" | "cancelled",
|
||||
status: "queued" | "running" | "completed" | "failed" | "cancelled",
|
||||
action: string,
|
||||
error?: string
|
||||
): void {
|
||||
@@ -1056,7 +1056,7 @@ export function getServerSettings(db: Db): ServerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
const settingsStmt = db.prepare("SELECT key, value FROM client_settings WHERE key IN ('server.dataSources', 'server.exportPipelines')");
|
||||
const settingsStmt = db.prepare("SELECT key, value FROM client_settings WHERE key IN ('server.dataSources', 'server.exportPipelines', 'server.llm')");
|
||||
const settingsRows = settingsStmt.all() as Array<{ key: string; value: string }>;
|
||||
let dataSources: Record<string, boolean> = {
|
||||
sec_filings: true,
|
||||
@@ -1066,6 +1066,7 @@ export function getServerSettings(db: Db): ServerSettings {
|
||||
press_releases: true,
|
||||
};
|
||||
let exportPipelines: Record<string, unknown> = {};
|
||||
let llm: ServerSettings["llm"] = undefined;
|
||||
for (const row of settingsRows) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.value) as unknown;
|
||||
@@ -1075,6 +1076,9 @@ export function getServerSettings(db: Db): ServerSettings {
|
||||
} else if (row.key === "server.exportPipelines") {
|
||||
const value = z.record(z.unknown()).safeParse(parsed);
|
||||
if (value.success) exportPipelines = value.data;
|
||||
} else if (row.key === "server.llm") {
|
||||
const value = ServerSettingsSchema.shape.llm.safeParse(parsed);
|
||||
if (value.success) llm = value.data;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed server settings.
|
||||
@@ -1085,6 +1089,7 @@ export function getServerSettings(db: Db): ServerSettings {
|
||||
agentConfigs,
|
||||
dataSources,
|
||||
exportPipelines,
|
||||
...(llm ? { llm } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1114,4 +1119,7 @@ export function updateServerSettings(
|
||||
if (settings.exportPipelines !== undefined) {
|
||||
updateClientSetting(db, "server.exportPipelines", settings.exportPipelines);
|
||||
}
|
||||
if (settings.llm !== undefined) {
|
||||
updateClientSetting(db, "server.llm", settings.llm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* LLM client for Pi API (Inflection AI)
|
||||
* Provides streaming and non-streaming completion functions
|
||||
* LLM client — delegates to the Pi Coding Agent SDK
|
||||
*
|
||||
* This module keeps the same public API surface (streamResponse, complete,
|
||||
* isConfigured) but delegates all LLM interaction to the SDK adapter in
|
||||
* `piSdk.ts`.
|
||||
*
|
||||
* The old raw-fetch-to-api.pi.ai implementation has been removed.
|
||||
*/
|
||||
|
||||
const PI_API_URL = "https://api.pi.ai/v1/chat/completions";
|
||||
const client = {
|
||||
apiKey: process.env.PI_API_KEY || "",
|
||||
};
|
||||
import { createSession, isConfigured as sdkIsConfigured } from "./piSdk.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (kept for backward compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StreamOptions {
|
||||
onProgress?: (text: string) => void;
|
||||
@@ -19,189 +25,79 @@ export interface CompletionOptions {
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = "pi";
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stream a response from Pi
|
||||
* Check if an API key is configured for any supported provider.
|
||||
* Delegates to the SDK adapter which checks both env vars and auth.json.
|
||||
*/
|
||||
export function isConfigured(): boolean {
|
||||
return sdkIsConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a response from the LLM.
|
||||
*
|
||||
* Creates a one-shot in-memory SDK session, sends the prompt, and collects
|
||||
* the streamed text via the event subscription.
|
||||
*/
|
||||
export async function streamResponse(
|
||||
prompt: string,
|
||||
options: StreamOptions & CompletionOptions = {}
|
||||
options: StreamOptions & CompletionOptions = {},
|
||||
): Promise<string> {
|
||||
const {
|
||||
model = DEFAULT_MODEL,
|
||||
maxTokens = DEFAULT_MAX_TOKENS,
|
||||
temperature = 0,
|
||||
onProgress,
|
||||
signal,
|
||||
} = options;
|
||||
const { onProgress } = options;
|
||||
|
||||
if (!client.apiKey) {
|
||||
throw new Error("PI_API_KEY is not set");
|
||||
if (!isConfigured()) {
|
||||
throw new Error(
|
||||
"No LLM API key configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, ZAI_API_KEY, or another provider key.",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(PI_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${client.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Pi API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Failed to get response body reader");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const session = await createSession();
|
||||
let fullResponse = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ") && line !== "data: [DONE]") {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const content = data.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
fullResponse += content;
|
||||
onProgress?.(content);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
session.subscribe((event) => {
|
||||
if (
|
||||
event.type === "message_update" &&
|
||||
event.assistantMessageEvent.type === "text_delta"
|
||||
) {
|
||||
const delta = event.assistantMessageEvent.delta;
|
||||
fullResponse += delta;
|
||||
onProgress?.(delta);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt(prompt);
|
||||
return fullResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a complete response from Pi (non-streaming)
|
||||
* Get a complete (non-streaming) response from the LLM.
|
||||
*/
|
||||
export async function complete(
|
||||
prompt: string,
|
||||
options: CompletionOptions = {}
|
||||
_options: CompletionOptions = {},
|
||||
): Promise<string> {
|
||||
const {
|
||||
model = DEFAULT_MODEL,
|
||||
maxTokens = DEFAULT_MAX_TOKENS,
|
||||
temperature = 0,
|
||||
} = options;
|
||||
|
||||
if (!client.apiKey) {
|
||||
throw new Error("PI_API_KEY is not set");
|
||||
if (!isConfigured()) {
|
||||
throw new Error(
|
||||
"No LLM API key configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, ZAI_API_KEY, or another provider key.",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(PI_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${client.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
}),
|
||||
const session = await createSession();
|
||||
let result = "";
|
||||
|
||||
session.subscribe((event) => {
|
||||
if (
|
||||
event.type === "message_update" &&
|
||||
event.assistantMessageEvent.type === "text_delta"
|
||||
) {
|
||||
result += event.assistantMessageEvent.delta;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Pi API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.choices?.[0]?.message?.content ?? "";
|
||||
await session.prompt(prompt);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API key is configured
|
||||
*/
|
||||
export function isConfigured(): boolean {
|
||||
return !!client.apiKey && client.apiKey.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream structured JSON response
|
||||
* Useful for agents that need to return structured data
|
||||
*/
|
||||
export async function streamStructuredResponse<T>(
|
||||
prompt: string,
|
||||
schema: Record<string, unknown>,
|
||||
options: StreamOptions & CompletionOptions = {}
|
||||
): Promise<T> {
|
||||
const structuredPrompt = `${prompt}
|
||||
|
||||
Please respond with a JSON object that follows this structure:
|
||||
${JSON.stringify(schema, null, 2)}
|
||||
|
||||
Your response must be valid JSON only, with no additional text or explanation.`;
|
||||
|
||||
const response = await streamResponse(structuredPrompt, options);
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
return JSON.parse(response) as T;
|
||||
} catch (error) {
|
||||
// If the response isn't valid JSON, try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]) as T;
|
||||
}
|
||||
throw new Error("Failed to parse structured response as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete with structured JSON response (non-streaming)
|
||||
*/
|
||||
export async function completeStructured<T>(
|
||||
prompt: string,
|
||||
schema: Record<string, unknown>,
|
||||
options: CompletionOptions = {}
|
||||
): Promise<T> {
|
||||
const structuredPrompt = `${prompt}
|
||||
|
||||
Please respond with a JSON object that follows this structure:
|
||||
${JSON.stringify(schema, null, 2)}
|
||||
|
||||
Your response must be valid JSON only, with no additional text or explanation.`;
|
||||
|
||||
const response = await complete(structuredPrompt, options);
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
return JSON.parse(response) as T;
|
||||
} catch (error) {
|
||||
// If the response isn't valid JSON, try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]) as T;
|
||||
}
|
||||
throw new Error("Failed to parse structured response as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export default client;
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
/**
|
||||
* LLM module exports
|
||||
*
|
||||
* isConfigured is exported from client.ts (which has its own implementation).
|
||||
* The piSdk.ts also has isConfigured but it's only exported here for direct
|
||||
* access to SDK internals.
|
||||
*/
|
||||
|
||||
export * from "./client.js";
|
||||
export { complete, streamResponse, isConfigured } from "./client.js";
|
||||
export {
|
||||
createSession,
|
||||
getAuthStorage,
|
||||
getModelRegistry,
|
||||
setActiveModelSelection,
|
||||
getActiveModelSelection,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
abortSession,
|
||||
} from "./piSdk.js";
|
||||
export type { CreateSessionOptions, ModelSelection } from "./piSdk.js";
|
||||
export * from "./prompts.js";
|
||||
export * from "./context.js";
|
||||
|
||||
25
packages/shared/src/llm/piSdk.test.ts
Normal file
25
packages/shared/src/llm/piSdk.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createSession, setActiveModelSelection } from "./piSdk.js";
|
||||
|
||||
describe("Pi SDK adapter", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.ZAI_API_KEY;
|
||||
setActiveModelSelection(undefined);
|
||||
});
|
||||
|
||||
it("creates sessions with the selected ZAI GLM model and system prompt", async () => {
|
||||
process.env.ZAI_API_KEY = "test-zai-key";
|
||||
|
||||
const session = await createSession({
|
||||
systemPrompt: "You are a MosaicIQ test agent.",
|
||||
modelSelection: { provider: "zai", modelId: "glm-5.1", thinkingLevel: "medium" },
|
||||
});
|
||||
|
||||
expect(session.model?.provider).toBe("zai");
|
||||
expect(session.model?.id).toBe("glm-5.1");
|
||||
expect(session.thinkingLevel).toBe("medium");
|
||||
expect(session.systemPrompt).toBe("You are a MosaicIQ test agent.");
|
||||
|
||||
session.dispose();
|
||||
});
|
||||
});
|
||||
188
packages/shared/src/llm/piSdk.ts
Normal file
188
packages/shared/src/llm/piSdk.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Pi Coding Agent SDK adapter for MosaicIQ
|
||||
*
|
||||
* This module wraps the Pi Coding Agent SDK (NOT the Pi/Inflection AI chatbot API).
|
||||
* The SDK is an agent framework that supports multiple LLM providers (Anthropic,
|
||||
* OpenAI, DeepSeek, Google, etc.). We do NOT use the Pi/Inflection AI service.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-initialized singletons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let authStorage: AuthStorage | undefined;
|
||||
let modelRegistry: ModelRegistry | undefined;
|
||||
let activeModelSelection: ModelSelection | undefined;
|
||||
|
||||
export interface ModelSelection {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
export function getAuthStorage(): AuthStorage {
|
||||
if (!authStorage) {
|
||||
authStorage = AuthStorage.create();
|
||||
}
|
||||
return authStorage;
|
||||
}
|
||||
|
||||
export function getModelRegistry(): ModelRegistry {
|
||||
if (!modelRegistry) {
|
||||
modelRegistry = ModelRegistry.create(getAuthStorage());
|
||||
}
|
||||
return modelRegistry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if at least one supported provider has an API key configured
|
||||
* (via env var or auth storage / auth.json).
|
||||
*
|
||||
* Checks environment variables first (fast, synchronous), then falls back to
|
||||
* the SDK's ModelRegistry which inspects auth.json.
|
||||
*/
|
||||
export function isConfigured(): boolean {
|
||||
// Fast path: check env vars synchronously
|
||||
if (
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
process.env.DEEPSEEK_API_KEY ||
|
||||
process.env.GEMINI_API_KEY ||
|
||||
process.env.ZAI_API_KEY
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Slow path: check auth.json via the SDK registry
|
||||
try {
|
||||
const registry = getModelRegistry();
|
||||
return registry.getAvailable().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
/** System prompt injected at session creation */
|
||||
systemPrompt?: string;
|
||||
/** Explicit model selection. Falls back to runtime selection, then SDK defaults. */
|
||||
modelSelection?: ModelSelection;
|
||||
/** Custom tools available to the agent */
|
||||
customTools?: unknown[];
|
||||
/** Event listener for streaming / lifecycle events */
|
||||
onEvent?: (event: AgentSessionEvent) => void;
|
||||
}
|
||||
|
||||
export function setActiveModelSelection(selection: ModelSelection | undefined): void {
|
||||
activeModelSelection = selection;
|
||||
}
|
||||
|
||||
export function getActiveModelSelection(): ModelSelection | undefined {
|
||||
return activeModelSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-memory agent session with custom tools.
|
||||
*
|
||||
* We deliberately:
|
||||
* - Use `SessionManager.inMemory()` — MosaicIQ has its own SQLite persistence.
|
||||
* - Set `compaction: { enabled: false }` — agents are short-lived per run.
|
||||
* - Pass `tools: []` — no built-in coding tools (read/write/edit/bash).
|
||||
* Only our domain-specific custom tools are provided.
|
||||
*/
|
||||
export async function createSession(
|
||||
options: CreateSessionOptions = {},
|
||||
): Promise<AgentSession> {
|
||||
const registry = getModelRegistry();
|
||||
const modelSelection = options.modelSelection ?? activeModelSelection;
|
||||
const selectedModel = modelSelection
|
||||
? registry.find(modelSelection.provider, modelSelection.modelId)
|
||||
: undefined;
|
||||
|
||||
if (modelSelection && !selectedModel) {
|
||||
throw new Error(`Model "${modelSelection.provider}/${modelSelection.modelId}" is not known to the Pi SDK.`);
|
||||
}
|
||||
|
||||
if (selectedModel && !registry.hasConfiguredAuth(selectedModel)) {
|
||||
throw new Error(
|
||||
`Model "${selectedModel.provider}/${selectedModel.id}" is selected, but no API key is configured for provider "${selectedModel.provider}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
model: selectedModel,
|
||||
thinkingLevel: modelSelection?.thinkingLevel,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
authStorage: getAuthStorage(),
|
||||
modelRegistry: registry,
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
...(modelSelection
|
||||
? {
|
||||
defaultProvider: modelSelection.provider,
|
||||
defaultModel: modelSelection.modelId,
|
||||
defaultThinkingLevel: modelSelection.thinkingLevel,
|
||||
}
|
||||
: {}),
|
||||
compaction: { enabled: false },
|
||||
retry: { enabled: true, maxRetries: 3 },
|
||||
}),
|
||||
tools: [],
|
||||
customTools: (options.customTools ?? []) as any[],
|
||||
});
|
||||
|
||||
if (options.onEvent) {
|
||||
session.subscribe(options.onEvent);
|
||||
}
|
||||
|
||||
if (options.systemPrompt) {
|
||||
const agentSession = session as unknown as { _baseSystemPrompt?: string };
|
||||
agentSession._baseSystemPrompt = options.systemPrompt;
|
||||
session.agent.state.systemPrompt = options.systemPrompt;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active sessions (for abort / cancellation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const activeSessions = new Map<string, AgentSession>();
|
||||
|
||||
export function registerSession(runId: string, session: AgentSession): void {
|
||||
activeSessions.set(runId, session);
|
||||
}
|
||||
|
||||
export function unregisterSession(runId: string): void {
|
||||
activeSessions.delete(runId);
|
||||
}
|
||||
|
||||
export async function abortSession(runId: string): Promise<boolean> {
|
||||
const session = activeSessions.get(runId);
|
||||
if (session) {
|
||||
await session.abort();
|
||||
activeSessions.delete(runId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Db } from "../db/database.js";
|
||||
import { addAgentRunStep, createAgentRun, getPortfolio, listAgentRunSteps, listAgents, pauseAgent, storeAgentOutput, updateAgentConfig, updateAgentRunCompletion } from "../db/queries.js";
|
||||
import {
|
||||
addAgentRunStep,
|
||||
createAgentRun,
|
||||
getPortfolio,
|
||||
listAgentRunSteps,
|
||||
listAgents,
|
||||
pauseAgent,
|
||||
storeAgentOutput,
|
||||
updateAgentConfig,
|
||||
updateAgentRunCompletion,
|
||||
} from "../db/queries.js";
|
||||
import { executeAgent } from "../agents/runner.js";
|
||||
import { buildChatContext } from "../llm/context.js";
|
||||
import { complete, isConfigured } from "../llm/client.js";
|
||||
import { getAgentPrompt } from "../llm/prompts.js";
|
||||
import { isConfigured } from "../llm/client.js";
|
||||
import { fail, ok } from "./result.js";
|
||||
import type { RpcHandlers } from "./types.js";
|
||||
|
||||
@@ -20,6 +28,7 @@ type AgentMethod =
|
||||
export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
|
||||
return {
|
||||
"agent.list": ({ companyId }) => ok("agent.list", { agents: listAgents(db, companyId) }),
|
||||
|
||||
"agent.start": ({ agentId, companyId }) => {
|
||||
const { runId } = createAgentRun(db, agentId, companyId, "queued");
|
||||
void executeAgent(db, agentId, companyId, { runId }).catch((error) => {
|
||||
@@ -27,7 +36,9 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
|
||||
});
|
||||
return ok("agent.start", { runId });
|
||||
},
|
||||
|
||||
"agent.pause": ({ agentId }) => ok("agent.pause", pauseAgent(db, agentId)),
|
||||
|
||||
"agent.restart": ({ agentId }) => {
|
||||
const companyId = getPortfolio(db)?.activeCompanyId;
|
||||
if (!companyId) return fail("VALIDATION_ERROR", "Select or add a company before restarting an agent.");
|
||||
@@ -37,32 +48,48 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
|
||||
});
|
||||
return ok("agent.restart", { runId });
|
||||
},
|
||||
|
||||
"agent.chat": async ({ agentId, message }) => {
|
||||
const portfolio = getPortfolio(db);
|
||||
const companyId = portfolio?.activeCompanyId;
|
||||
if (!companyId) return fail("VALIDATION_ERROR", "Select or add a company before chatting with an agent.");
|
||||
if (!isConfigured()) return fail("AGENT_FAILED", "LLM API key is not configured. Set PI_API_KEY to use agent chat.");
|
||||
if (!isConfigured()) return fail("AGENT_FAILED", "LLM API key is not configured. Set ANTHROPIC_API_KEY or another supported provider key to use agent chat.");
|
||||
|
||||
const { runId } = createAgentRun(db, agentId, companyId, "running");
|
||||
addAgentRunStep(db, runId, 1, "Build context", "Loaded local company, memo, filings, and model context.");
|
||||
addAgentRunStep(db, runId, 1, "Build context", "Using SDK session with custom tools for company data.");
|
||||
|
||||
try {
|
||||
const context = await buildChatContext(db, companyId, message);
|
||||
// Use the new SDK-based runner for chat too
|
||||
addAgentRunStep(db, runId, 2, "Generate response", `Sent analyst message to ${agentId}.`);
|
||||
const response = await complete(getAgentPrompt("chat", context));
|
||||
storeAgentOutput(db, runId, agentId, companyId, { response }, response);
|
||||
updateAgentRunCompletion(db, runId, "completed", "Chat response generated");
|
||||
addAgentRunStep(db, runId, 3, "Persist response", "Saved chat response to the local run log.");
|
||||
return ok("agent.chat", { response });
|
||||
const result = await executeAgent(db, agentId, companyId, {
|
||||
runId,
|
||||
userMessage: message,
|
||||
});
|
||||
|
||||
if (result.status === "completed" && typeof result.output === "object" && result.output !== null) {
|
||||
const chatOutput = result.output as { response?: string; text?: string };
|
||||
const response = chatOutput.response ?? chatOutput.text ?? result.rawResponse;
|
||||
storeAgentOutput(db, runId, agentId, companyId, { response }, result.rawResponse);
|
||||
updateAgentRunCompletion(db, runId, "completed", "Chat response generated");
|
||||
addAgentRunStep(db, runId, 3, "Persist response", "Saved chat response to the local run log.");
|
||||
return ok("agent.chat", { response });
|
||||
} else {
|
||||
throw new Error(result.error || "Agent chat failed");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
updateAgentRunCompletion(db, runId, "failed", "Chat failed", message);
|
||||
return fail("AGENT_FAILED", message, error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
updateAgentRunCompletion(db, runId, "failed", "Chat failed", msg);
|
||||
return fail("AGENT_FAILED", msg, error);
|
||||
}
|
||||
},
|
||||
|
||||
"agent.configure": ({ agentId, config }) => {
|
||||
updateAgentConfig(db, agentId, config);
|
||||
return ok("agent.configure", { ok: true });
|
||||
},
|
||||
|
||||
"agent.getTrace": ({ runId }) => ok("agent.getTrace", { steps: listAgentRunSteps(db, runId) }),
|
||||
|
||||
"agent.runPipeline": ({ companyId, pipeline }) => {
|
||||
const pipelineAgents: Record<string, string[]> = {
|
||||
research: ["sf", "cr", "fm", "va", "mw", "pa"],
|
||||
@@ -71,6 +98,7 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
|
||||
};
|
||||
const agentIds = pipelineAgents[pipeline];
|
||||
if (!agentIds) return fail("VALIDATION_ERROR", `Unknown pipeline: ${pipeline}`);
|
||||
|
||||
const runIds = agentIds.map((pipelineAgentId) => {
|
||||
const { runId } = createAgentRun(db, pipelineAgentId, companyId, "queued");
|
||||
void executeAgent(db, pipelineAgentId, companyId, { runId }).catch((error) => {
|
||||
@@ -78,6 +106,7 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
|
||||
});
|
||||
return runId;
|
||||
});
|
||||
|
||||
return ok("agent.runPipeline", { runIds });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { closeDatabase, type Db, initDatabase } from "../db/database.js";
|
||||
import { createRpcHandler } from "../db/rpcHandler.js";
|
||||
import { getActiveModelSelection, setActiveModelSelection } from "../llm/piSdk.js";
|
||||
|
||||
describe("model RPC", () => {
|
||||
let db: Db;
|
||||
@@ -13,6 +14,8 @@ describe("model RPC", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ZAI_API_KEY;
|
||||
setActiveModelSelection(undefined);
|
||||
closeDatabase(db);
|
||||
});
|
||||
|
||||
@@ -42,4 +45,30 @@ describe("model RPC", () => {
|
||||
expect(result.error.code).toBe("INTERNAL_ERROR");
|
||||
expect(result.error.message).toBe("Could not load model for company.");
|
||||
});
|
||||
|
||||
it("lists SDK-discovered ZAI GLM models", async () => {
|
||||
process.env.ZAI_API_KEY = "test-zai-key";
|
||||
|
||||
const result = await rpc("model.list", undefined);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.data.models).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
name: "GLM-5.1",
|
||||
available: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the runtime SDK model selection", async () => {
|
||||
process.env.ZAI_API_KEY = "test-zai-key";
|
||||
|
||||
const result = await rpc("model.set", { provider: "zai", modelId: "glm-5.1" });
|
||||
|
||||
expect(result).toEqual({ ok: true, data: { ok: true, provider: "zai", modelId: "glm-5.1" } });
|
||||
expect(getActiveModelSelection()).toEqual({ provider: "zai", modelId: "glm-5.1" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Db } from "../db/database.js";
|
||||
import { createModelRow, deleteModelRow, getModel, resolveCompany, updateModelCell } from "../db/queries.js";
|
||||
import { getModelRegistry, setActiveModelSelection } from "../llm/piSdk.js";
|
||||
import { fail, ok } from "./result.js";
|
||||
import type { RpcHandlers } from "./types.js";
|
||||
|
||||
@@ -10,7 +11,17 @@ function errorDetail(operation: string, error: unknown): { operation: string; me
|
||||
};
|
||||
}
|
||||
|
||||
export function modelHandlers(db: Db): RpcHandlers<"model.get" | "model.updateCell" | "model.createRow" | "model.deleteRow" | "model.runScenario"> {
|
||||
const DISPLAYED_LLM_PROVIDERS = new Set(["anthropic", "openai", "deepseek", "google", "zai"]);
|
||||
|
||||
export function modelHandlers(db: Db): RpcHandlers<
|
||||
"model.get" |
|
||||
"model.list" |
|
||||
"model.set" |
|
||||
"model.updateCell" |
|
||||
"model.createRow" |
|
||||
"model.deleteRow" |
|
||||
"model.runScenario"
|
||||
> {
|
||||
return {
|
||||
"model.get": ({ companyId, tab }) => {
|
||||
const company = resolveCompany(db, companyId);
|
||||
@@ -21,21 +32,54 @@ export function modelHandlers(db: Db): RpcHandlers<"model.get" | "model.updateCe
|
||||
return fail("INTERNAL_ERROR", "Could not load model for company.", errorDetail("getModel", error));
|
||||
}
|
||||
},
|
||||
|
||||
"model.list": async () => {
|
||||
const registry = getModelRegistry();
|
||||
const available = await registry.getAvailable();
|
||||
const availableIds = new Set(available.map((m) => `${m.provider}/${m.id}`));
|
||||
|
||||
const models = registry
|
||||
.getAll()
|
||||
.filter((model) => DISPLAYED_LLM_PROVIDERS.has(model.provider))
|
||||
.map((model) => ({
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
name: model.name ?? model.id,
|
||||
available: availableIds.has(`${model.provider}/${model.id}`),
|
||||
}));
|
||||
|
||||
return ok("model.list", { models });
|
||||
},
|
||||
|
||||
"model.set": ({ provider, modelId, thinkingLevel }) => {
|
||||
const registry = getModelRegistry();
|
||||
const model = registry.find(provider, modelId);
|
||||
if (!model) return fail("VALIDATION_ERROR", `Model "${provider}/${modelId}" is not supported by the Pi SDK.`);
|
||||
if (!registry.hasConfiguredAuth(model)) {
|
||||
return fail("VALIDATION_ERROR", `No API key is configured for provider "${provider}".`);
|
||||
}
|
||||
setActiveModelSelection({ provider, modelId, thinkingLevel });
|
||||
return ok("model.set", { ok: true, provider, modelId });
|
||||
},
|
||||
|
||||
"model.updateCell": ({ companyId, tab, row, col, value }) => {
|
||||
const company = resolveCompany(db, companyId);
|
||||
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
||||
return ok("model.updateCell", updateModelCell(db, company.id, tab, row, col, value));
|
||||
},
|
||||
|
||||
"model.createRow": ({ companyId, tab, label, kind, values }) => {
|
||||
const company = resolveCompany(db, companyId);
|
||||
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
||||
return ok("model.createRow", createModelRow(db, company.id, tab, { label, kind, values: values ?? [] }));
|
||||
},
|
||||
|
||||
"model.deleteRow": ({ companyId, tab, row }) => {
|
||||
const company = resolveCompany(db, companyId);
|
||||
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
||||
return ok("model.deleteRow", { ok: deleteModelRow(db, company.id, tab, row) });
|
||||
},
|
||||
|
||||
"model.runScenario": ({ companyId, overrides }) => {
|
||||
const company = resolveCompany(db, companyId);
|
||||
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { closeDatabase, type Db, initDatabase } from "../db/database.js";
|
||||
import { createRpcHandler } from "../db/rpcHandler.js";
|
||||
import { getActiveModelSelection, setActiveModelSelection } from "../llm/piSdk.js";
|
||||
|
||||
describe("settings RPC", () => {
|
||||
let db: Db;
|
||||
@@ -12,6 +13,7 @@ describe("settings RPC", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActiveModelSelection(undefined);
|
||||
closeDatabase(db);
|
||||
});
|
||||
|
||||
@@ -40,4 +42,18 @@ describe("settings RPC", () => {
|
||||
if (!get.ok) return;
|
||||
expect(get.data.settings).toMatchObject({ agentConfigs: { "agent-1": { enabled: true } } });
|
||||
});
|
||||
|
||||
it("updates runtime LLM model selection from server settings", async () => {
|
||||
const update = await rpc("settings.update", {
|
||||
scope: "server",
|
||||
changes: { llm: { provider: "zai", modelId: "glm-5.1", thinkingLevel: "medium" } },
|
||||
});
|
||||
|
||||
expect(update).toEqual({ ok: true, data: { ok: true } });
|
||||
expect(getActiveModelSelection()).toEqual({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
thinkingLevel: "medium",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClientSettings, ServerSettings } from "@mosaiciq/contracts/rpc";
|
||||
import type { Db } from "../db/database.js";
|
||||
import { getClientSettings, getServerSettings, updateClientSettings, updateServerSettings } from "../db/queries.js";
|
||||
import { setActiveModelSelection } from "../llm/piSdk.js";
|
||||
import { ok } from "./result.js";
|
||||
import type { RpcHandlers } from "./types.js";
|
||||
|
||||
@@ -14,7 +15,15 @@ export function settingsHandlers(db: Db): RpcHandlers<"settings.get" | "settings
|
||||
if (scope === "client") {
|
||||
updateClientSettings(db, changes as Partial<ClientSettings>);
|
||||
} else {
|
||||
updateServerSettings(db, changes as Partial<ServerSettings>);
|
||||
const serverChanges = changes as Partial<ServerSettings>;
|
||||
updateServerSettings(db, serverChanges);
|
||||
if (serverChanges.llm?.provider && serverChanges.llm.modelId) {
|
||||
setActiveModelSelection({
|
||||
provider: serverChanges.llm.provider,
|
||||
modelId: serverChanges.llm.modelId,
|
||||
thinkingLevel: serverChanges.llm.thinkingLevel,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ok("settings.update", { ok: true });
|
||||
},
|
||||
|
||||
208
packages/shared/src/tools/companyTools.ts
Normal file
208
packages/shared/src/tools/companyTools.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Custom tools: Company, filings, and earnings data access
|
||||
*/
|
||||
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import type { Db } from "../db/database.js";
|
||||
import {
|
||||
getCompany,
|
||||
getCompanyByTicker,
|
||||
resolveCompany,
|
||||
listFilings,
|
||||
getEarningsSchedule,
|
||||
listCatalysts,
|
||||
listRisks,
|
||||
listAlerts,
|
||||
} from "../db/queries.js";
|
||||
|
||||
export function createCompanyTools(db: Db) {
|
||||
const getCompanyInfo = defineTool({
|
||||
name: "get_company_info",
|
||||
label: "Get Company Info",
|
||||
description:
|
||||
"Get company details including name, ticker, sector, price, thesis, and metadata. Accepts either a company ID or ticker symbol.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({
|
||||
description: "Company ID or ticker symbol (e.g. 'AAPL' or 'company-123')",
|
||||
}),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Company "${params.companyIdOrTicker}" not found.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(company, null, 2),
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getFilingsTool = defineTool({
|
||||
name: "get_filings",
|
||||
label: "Get SEC Filings",
|
||||
description:
|
||||
"List SEC filings for a company (10-K, 10-Q, 8-K, DEF 14A, etc.). Returns form type, title, filed date, and key changes.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({
|
||||
description: "Company ID or ticker symbol",
|
||||
}),
|
||||
limit: Type.Optional(
|
||||
Type.Number({ description: "Max filings to return (default 20)", default: 20 }),
|
||||
),
|
||||
since: Type.Optional(
|
||||
Type.String({ description: "Only filings after this date (YYYY-MM-DD)" }),
|
||||
),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
const filings = listFilings(db, company.id, params.since);
|
||||
const limited = filings.slice(0, params.limit ?? 20);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(
|
||||
{ companyId: company.id, ticker: company.ticker, count: limited.length, filings: limited },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getEarningsTool = defineTool({
|
||||
name: "get_earnings",
|
||||
label: "Get Earnings Schedule",
|
||||
description:
|
||||
"Get upcoming and historical earnings dates, actual vs expected revenue and EPS.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({
|
||||
description: "Company ID or ticker symbol",
|
||||
}),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
const schedule = getEarningsSchedule(db, company.id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(schedule, null, 2),
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getCatalystsTool = defineTool({
|
||||
name: "get_catalysts",
|
||||
label: "Get Catalysts",
|
||||
description: "List catalysts (upcoming events) for a company with impact assessment and thesis relevance.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({
|
||||
description: "Company ID or ticker symbol",
|
||||
}),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
const catalysts = listCatalysts(db, company.id);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(catalysts, null, 2) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getRisksTool = defineTool({
|
||||
name: "get_risks",
|
||||
label: "Get Risks",
|
||||
description: "List identified risks for a company, categorized by type with severity and likelihood.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({
|
||||
description: "Company ID or ticker symbol",
|
||||
}),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
const risks = listRisks(db, company.id);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(risks, null, 2) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getAlertsTool = defineTool({
|
||||
name: "get_alerts",
|
||||
label: "Get Alerts",
|
||||
description: "List recent alerts (filing, price move, earnings surprise, peer event) for a company.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.Optional(
|
||||
Type.String({ description: "Company ID or ticker symbol (omit for all companies)" }),
|
||||
),
|
||||
since: Type.Optional(
|
||||
Type.String({ description: "Only alerts after this timestamp" }),
|
||||
),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = params.companyIdOrTicker
|
||||
? resolveCompany(db, params.companyIdOrTicker)
|
||||
: undefined;
|
||||
const alerts = listAlerts(db, company?.id, params.since);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(alerts, null, 2) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [getCompanyInfo, getFilingsTool, getEarningsTool, getCatalystsTool, getRisksTool, getAlertsTool];
|
||||
}
|
||||
250
packages/shared/src/tools/fileTools.ts
Normal file
250
packages/shared/src/tools/fileTools.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Custom tools: File manipulation (Excel, CSV)
|
||||
*
|
||||
* The Pi SDK's built-in read/write tools only handle text files.
|
||||
* These tools let agents read and write Excel, CSV, and other financial file formats.
|
||||
*/
|
||||
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
export function createFileTools() {
|
||||
const readExcel = defineTool({
|
||||
name: "read_excel",
|
||||
label: "Read Excel File",
|
||||
description:
|
||||
"Read an Excel (.xlsx) file and return sheet data as JSON. Supports sheet selection and row limits.",
|
||||
parameters: Type.Object({
|
||||
path: Type.String({ description: "Path to the .xlsx file" }),
|
||||
sheetName: Type.Optional(
|
||||
Type.String({ description: "Sheet name (default: first sheet)" }),
|
||||
),
|
||||
maxRows: Type.Optional(
|
||||
Type.Number({ description: "Max rows to return (default: 100)", default: 100 }),
|
||||
),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
try {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(params.path);
|
||||
|
||||
const sheet = params.sheetName
|
||||
? workbook.getWorksheet(params.sheetName)
|
||||
: workbook.worksheets[0];
|
||||
|
||||
if (!sheet) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Sheet not found: ${params.sheetName ?? "(first sheet)"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const headers: string[] = [];
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
const limit = params.maxRows ?? 100;
|
||||
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) {
|
||||
row.eachCell((cell) => {
|
||||
headers.push(String(cell.value ?? `col_${headers.length}`));
|
||||
});
|
||||
} else if (rowNumber <= limit + 1) {
|
||||
const values: Record<string, unknown> = {};
|
||||
row.eachCell((cell, colNumber) => {
|
||||
values[headers[colNumber - 1] ?? `col_${colNumber}`] = cell.value;
|
||||
});
|
||||
rows.push(values);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(
|
||||
{ file: params.path, sheetName: sheet.name, headers, rowCount: rows.length, rows },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Failed to read Excel file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const writeExcel = defineTool({
|
||||
name: "write_excel",
|
||||
label: "Write Excel File",
|
||||
description:
|
||||
"Write structured data to a new Excel (.xlsx) file. Data is an array of objects (each object is a row, keys are column headers).",
|
||||
parameters: Type.Object({
|
||||
path: Type.String({ description: "Output file path (must end in .xlsx)" }),
|
||||
sheetName: Type.String({
|
||||
description: "Sheet name",
|
||||
default: "Sheet1",
|
||||
}),
|
||||
data: Type.Array(Type.Record(Type.String(), Type.Unknown()), {
|
||||
description: "Array of row objects. Keys become column headers.",
|
||||
}),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
try {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = "MosaicIQ";
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet(params.sheetName);
|
||||
|
||||
if (params.data.length > 0) {
|
||||
const headers = Object.keys(params.data[0]);
|
||||
sheet.addRow(headers);
|
||||
|
||||
// Style header row
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
|
||||
for (const rowData of params.data) {
|
||||
sheet.addRow(headers.map((h) => rowData[h] ?? ""));
|
||||
}
|
||||
|
||||
// Auto-fit column widths (approximate)
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const maxLen = Math.max(
|
||||
headers[i].length,
|
||||
...params.data.slice(0, 20).map((r: Record<string, unknown>) => String(r[headers[i]] ?? "").length),
|
||||
);
|
||||
sheet.getColumn(i + 1).width = Math.min(maxLen + 2, 40);
|
||||
}
|
||||
}
|
||||
|
||||
await workbook.xlsx.writeFile(params.path);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Wrote ${params.data.length} rows to ${params.path} (sheet: ${params.sheetName})`,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Failed to write Excel file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const readCsv = defineTool({
|
||||
name: "read_csv",
|
||||
label: "Read CSV File",
|
||||
description:
|
||||
"Parse a CSV file and return rows as JSON objects. Handles quoted fields and custom delimiters.",
|
||||
parameters: Type.Object({
|
||||
path: Type.String({ description: "Path to the CSV file" }),
|
||||
delimiter: Type.Optional(
|
||||
Type.String({ description: "Field delimiter (default: comma)", default: "," }),
|
||||
),
|
||||
maxRows: Type.Optional(
|
||||
Type.Number({ description: "Max rows to return (default: 500)", default: 500 }),
|
||||
),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
try {
|
||||
const fs = await import("fs/promises");
|
||||
const content = await fs.readFile(params.path, "utf-8");
|
||||
const delimiter = params.delimiter ?? ",";
|
||||
const lines = content.trim().split("\n");
|
||||
const headers = lines[0].split(delimiter).map((h) => h.trim().replace(/^"|"$/g, ""));
|
||||
const maxRows = params.maxRows ?? 500;
|
||||
const rows = lines.slice(1, maxRows + 1).map((line) => {
|
||||
const values = splitCsvLine(line, delimiter);
|
||||
const obj: Record<string, string> = {};
|
||||
headers.forEach((h, i) => {
|
||||
obj[h] = (values[i] ?? "").trim().replace(/^"|"$/g, "");
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ file: params.path, headers, rowCount: rows.length, rows }, null, 2),
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Failed to read CSV file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return [readExcel, writeExcel, readCsv];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple CSV line splitter that respects quoted fields.
|
||||
*/
|
||||
function splitCsvLine(line: string, delimiter: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === delimiter && !inQuotes) {
|
||||
result.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
result.push(current);
|
||||
return result;
|
||||
}
|
||||
27
packages/shared/src/tools/index.ts
Normal file
27
packages/shared/src/tools/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Tool barrel export
|
||||
*/
|
||||
|
||||
import type { Db } from "../db/database.js";
|
||||
import { createCompanyTools } from "./companyTools.js";
|
||||
import { createModelTools } from "./modelTools.js";
|
||||
import { createMemoTools } from "./memoTools.js";
|
||||
import { createValidationTools } from "./validationTools.js";
|
||||
import { createFileTools } from "./fileTools.js";
|
||||
|
||||
/**
|
||||
* Create all custom tools for a given DB instance.
|
||||
* These tools are passed to the Pi SDK session so the LLM can access
|
||||
* MosaicIQ's SQLite data and manipulate files directly.
|
||||
*/
|
||||
export function createAllTools(db: Db) {
|
||||
return [
|
||||
...createCompanyTools(db),
|
||||
...createModelTools(db),
|
||||
...createMemoTools(db),
|
||||
...createValidationTools(db),
|
||||
...createFileTools(),
|
||||
];
|
||||
}
|
||||
|
||||
export { createCompanyTools, createModelTools, createMemoTools, createValidationTools, createFileTools };
|
||||
165
packages/shared/src/tools/memoTools.ts
Normal file
165
packages/shared/src/tools/memoTools.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Custom tools: Investment memo read/write
|
||||
*/
|
||||
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import type { Db } from "../db/database.js";
|
||||
import {
|
||||
resolveCompany,
|
||||
getMemo,
|
||||
updateMemoSection,
|
||||
addMemoAnnotation,
|
||||
resolveMemoAnnotation,
|
||||
updateMemoSectionReview,
|
||||
} from "../db/queries.js";
|
||||
|
||||
export function createMemoTools(db: Db) {
|
||||
const getMemoTool = defineTool({
|
||||
name: "get_memo",
|
||||
label: "Get Investment Memo",
|
||||
description:
|
||||
"Get the full investment memo for a company including all sections, citations, annotations, and review status.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
const memo = getMemo(db, company.id);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(memo, null, 2) }],
|
||||
details: {} as any,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const updateSectionTool = defineTool({
|
||||
name: "update_memo_section",
|
||||
label: "Update Memo Section",
|
||||
description:
|
||||
"Update a specific section of the investment memo. Use this to write or revise content.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
sectionId: Type.String({ description: "Section ID (e.g. 'thesis', 'variant-perception', 'valuation')" }),
|
||||
content: Type.String({ description: "New section content in markdown" }),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const section = updateMemoSection(db, company.id, params.sectionId, {
|
||||
content: params.content,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Updated section "${params.sectionId}"` },
|
||||
],
|
||||
details: { section } as any,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Failed to update section: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const addAnnotationTool = defineTool({
|
||||
name: "add_memo_annotation",
|
||||
label: "Add Memo Annotation",
|
||||
description:
|
||||
"Add an annotation (highlight, comment, or strike) to a memo section for review.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
sectionId: Type.String({ description: "Section ID" }),
|
||||
kind: Type.Union(
|
||||
[Type.Literal("highlight"), Type.Literal("comment"), Type.Literal("strike")],
|
||||
{ description: "Annotation kind" },
|
||||
),
|
||||
selectedText: Type.String({ description: "The text being annotated" }),
|
||||
comment: Type.Optional(Type.String({ description: "Comment text (required for comment kind)" })),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
const annotation = addMemoAnnotation(db, company.id, {
|
||||
sectionId: params.sectionId,
|
||||
kind: params.kind,
|
||||
selectedText: params.selectedText,
|
||||
comment: params.comment,
|
||||
createdBy: "AI",
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "open",
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Added ${params.kind} annotation` }],
|
||||
details: { annotation } as any,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const reviewSectionTool = defineTool({
|
||||
name: "review_memo_section",
|
||||
label: "Update Memo Section Review",
|
||||
description:
|
||||
"Set the review status of a memo section (pending, in_review, approved, changes_requested).",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
sectionId: Type.String({ description: "Section ID" }),
|
||||
status: Type.Union(
|
||||
[
|
||||
Type.Literal("pending"),
|
||||
Type.Literal("in_review"),
|
||||
Type.Literal("approved"),
|
||||
Type.Literal("changes_requested"),
|
||||
],
|
||||
{ description: "New review status" },
|
||||
),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
const review = updateMemoSectionReview(db, company.id, params.sectionId, params.status);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Section "${params.sectionId}" review status set to "${params.status}"` },
|
||||
],
|
||||
details: { review } as any,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [getMemoTool, updateSectionTool, addAnnotationTool, reviewSectionTool];
|
||||
}
|
||||
153
packages/shared/src/tools/modelTools.ts
Normal file
153
packages/shared/src/tools/modelTools.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Custom tools: Financial model read/write
|
||||
*/
|
||||
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import type { Db } from "../db/database.js";
|
||||
import { resolveCompany, getModel, updateModelCell, createModelRow, deleteModelRow } from "../db/queries.js";
|
||||
|
||||
export function createModelTools(db: Db) {
|
||||
const getModelTool = defineTool({
|
||||
name: "get_financial_model",
|
||||
label: "Get Financial Model",
|
||||
description:
|
||||
"Get the financial model for a company. Returns headers (time periods) and rows (line items) with actual/forecast/total kinds.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({
|
||||
description: "Company ID or ticker symbol",
|
||||
}),
|
||||
tab: Type.Union(
|
||||
[Type.Literal("income"), Type.Literal("balance"), Type.Literal("operating")],
|
||||
{ description: "Which model tab to retrieve" },
|
||||
),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
const model = getModel(db, company.id, params.tab);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(model, null, 2) }],
|
||||
details: {} as any,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const updateCellTool = defineTool({
|
||||
name: "update_model_cell",
|
||||
label: "Update Model Cell",
|
||||
description: "Update a single cell in the financial model by row index and column index.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
tab: Type.String({ description: "Model tab (income, balance, operating)" }),
|
||||
row: Type.Number({ description: "Row index (0-based)" }),
|
||||
col: Type.Number({ description: "Column index (0-based)" }),
|
||||
value: Type.String({ description: "New cell value" }),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
const result = updateModelCell(db, company.id, params.tab, params.row, params.col, params.value);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: result.ok
|
||||
? `Updated cell [${params.row}, ${params.col}] = ${params.value}`
|
||||
: `Failed to update cell. Row or column out of range.`,
|
||||
},
|
||||
],
|
||||
isError: !result.ok,
|
||||
details: result as any,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const createRowTool = defineTool({
|
||||
name: "create_model_row",
|
||||
label: "Create Model Row",
|
||||
description: "Add a new row to the financial model (e.g. a new line item or forecast row).",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
tab: Type.String({ description: "Model tab" }),
|
||||
label: Type.String({ description: "Row label (e.g. 'Revenue', 'COGS')" }),
|
||||
kind: Type.Union(
|
||||
[Type.Literal("actual"), Type.Literal("forecast"), Type.Literal("total")],
|
||||
{ description: "Row kind" },
|
||||
),
|
||||
values: Type.Optional(
|
||||
Type.Array(Type.String(), { description: "Initial cell values" }),
|
||||
),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
const result = createModelRow(db, company.id, params.tab, {
|
||||
label: params.label,
|
||||
kind: params.kind,
|
||||
values: params.values ?? [],
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Created row "${params.label}" (${params.kind}) at position ${result.position}`,
|
||||
},
|
||||
],
|
||||
details: result as any,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const deleteRowTool = defineTool({
|
||||
name: "delete_model_row",
|
||||
label: "Delete Model Row",
|
||||
description: "Delete a row from the financial model by index.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
tab: Type.String({ description: "Model tab" }),
|
||||
row: Type.Number({ description: "Row index to delete (0-based)" }),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {} as any,
|
||||
};
|
||||
}
|
||||
const ok = deleteModelRow(db, company.id, params.tab, params.row);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: ok ? `Deleted row ${params.row}` : `Row ${params.row} not found or could not be deleted`,
|
||||
},
|
||||
],
|
||||
isError: !ok,
|
||||
details: {} as any,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [getModelTool, updateCellTool, createRowTool, deleteRowTool];
|
||||
}
|
||||
73
packages/shared/src/tools/validationTools.ts
Normal file
73
packages/shared/src/tools/validationTools.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Custom tools: Validation status queries
|
||||
*/
|
||||
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import type { Db } from "../db/database.js";
|
||||
import { resolveCompany, getMemo } from "../db/queries.js";
|
||||
|
||||
export function createValidationTools(db: Db) {
|
||||
const getValidationStatusTool = defineTool({
|
||||
name: "get_validation_status",
|
||||
label: "Get Validation Status",
|
||||
description:
|
||||
"Get the current validation/review status of all memo sections for a company. Shows which sections are verified, flagged, or unverified.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
const memo = getMemo(db, company.id);
|
||||
const reviewStatus = memo.sectionReviews.map((r) => ({
|
||||
sectionId: r.sectionId,
|
||||
status: r.status,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(reviewStatus, null, 2) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const getAnnotationsTool = defineTool({
|
||||
name: "get_memo_annotations",
|
||||
label: "Get Memo Annotations",
|
||||
description:
|
||||
"List all open annotations (comments, highlights, strikes) on the investment memo.",
|
||||
parameters: Type.Object({
|
||||
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
|
||||
openOnly: Type.Optional(
|
||||
Type.Boolean({ description: "Only return open (unresolved) annotations", default: true }),
|
||||
),
|
||||
}),
|
||||
execute: async (_, params) => {
|
||||
const company = resolveCompany(db, params.companyIdOrTicker);
|
||||
if (!company) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
|
||||
isError: true,
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
const memo = getMemo(db, company.id);
|
||||
const annotations = params.openOnly ?? true
|
||||
? memo.annotations.filter((a) => a.status === "open")
|
||||
: memo.annotations;
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(annotations, null, 2) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [getValidationStatusTool, getAnnotationsTool];
|
||||
}
|
||||
Reference in New Issue
Block a user