Research Agents: Lets you define research agents that chat and answer with citations from SEC filings, your documents, and the web, via Jaspers' research-store service.
| 1 | // The app admits only the SDK's root to a plugin, so the skill helpers come from there. |
| 2 | import { ACTIVATE_SKILL, runSkillTool, skillsPrompt, skillTools, type Skills } from '@jaspers-ai/sdk' |
| 3 | import type { Agent } from './agents.ts' |
| 4 | import { |
| 5 | calculationId, |
| 6 | passagesText, |
| 7 | systemPrompt, |
| 8 | type AgentMessage, |
| 9 | type Holding, |
| 10 | type Passage, |
| 11 | type Search, |
| 12 | type SkillUse, |
| 13 | } from './chat.ts' |
| 14 | import { CATEGORIES, CATEGORY_NAMES, readsText, tickerOf, type Category } from './sec.ts' |
| 15 | import { stepOf, thoughtOf } from './working.ts' |
| 16 | import { repairText, uncited, unknownRefs } from './cite.ts' |
| 17 | import { errorText } from './errors.ts' |
| 18 | import type { Hit } from './research.ts' |
| 19 | import { askedOf, mergeSearch } from './search.ts' |
| 20 | import { calculate } from './calc.ts' |
| 21 | |
| 22 | // One agent answering one message: call the model with the conversation and its tools, search_prism |
| 23 | // and calculate and, for a web agent, search_web and fetch_page, run its searches through the agent's |
| 24 | // pinned filters, and keep |
| 25 | // going until it answers without searching, however many searches that takes. An agent given skills |
| 26 | // sees their names and descriptions and loads one's instructions, or reads one's files, when a |
| 27 | // question calls for it, through the app's skills. Each search hands the |
| 28 | // model at most three passages, and only ones the store's reranker judged relevant. The model and |
| 29 | // the store arrive through the context, so the loop is tested with a scripted model. |
| 30 | |
| 31 | export interface ToolDefinition { |
| 32 | name: string |
| 33 | description: string |
| 34 | parameters: Record<string, unknown> |
| 35 | } |
| 36 | |
| 37 | export interface ToolCall { |
| 38 | id: string |
| 39 | name: string |
| 40 | input: Record<string, unknown> |
| 41 | } |
| 42 | |
| 43 | export type Turn = |
| 44 | | { role: 'user'; text: string } |
| 45 | | { role: 'assistant'; text: string; toolCalls: ToolCall[]; raw: unknown[] | null } |
| 46 | | { role: 'tool'; results: { callId: string; output: string; isError: boolean }[] } |
| 47 | |
| 48 | export interface Completion { |
| 49 | text: string |
| 50 | toolCalls: ToolCall[] |
| 51 | usage: { input: number; output: number } |
| 52 | raw: unknown[] | null |
| 53 | } |
| 54 | |
| 55 | /** What a run reaches: the model, the store's search, a page read into the store, and the app's skills. */ |
| 56 | export interface RunnerContext { |
| 57 | llm: { |
| 58 | complete(req: { |
| 59 | system: string |
| 60 | turns: Turn[] |
| 61 | tools?: ToolDefinition[] |
| 62 | model?: string |
| 63 | signal?: AbortSignal |
| 64 | }): Promise<Completion> |
| 65 | } |
| 66 | search(args: Record<string, unknown>, signal: AbortSignal): Promise<Hit[]> |
| 67 | /** Reads a public page into the store and says the address it ended at and the document it is. */ |
| 68 | fetchPage(url: string, signal: AbortSignal): Promise<{ url: string; doc: string }> |
| 69 | /** The companies the store holds and the years of their filings. */ |
| 70 | holdings(signal: AbortSignal): Promise<Holding[]> |
| 71 | /** |
| 72 | * Reads the kinds of a company's filings for the years into the store and waits until they are embedded, saying |
| 73 | * how far it is as it goes; answers what was read, and throws when it cannot. |
| 74 | */ |
| 75 | addCompany( |
| 76 | ticker: string, |
| 77 | years: number[], |
| 78 | categories: Category[], |
| 79 | signal: AbortSignal, |
| 80 | progress: (step: string) => Promise<void>, |
| 81 | ): Promise<string> |
| 82 | /** |
| 83 | * Before a search names a company the store holds: when SEC last listed its filings more than 15 |
| 84 | * minutes ago, reads in what it filed since and waits until it is embedded, saying how far it is. |
| 85 | */ |
| 86 | keepCurrent(ticker: string, signal: AbortSignal, progress: (step: string) => Promise<void>): Promise<void> |
| 87 | /** The web searched with the user's search provider; throws when none is set. */ |
| 88 | searchWeb(query: string, signal: AbortSignal): Promise<{ title: string; url: string; snippet: string }[]> |
| 89 | skills: Skills |
| 90 | } |
| 91 | |
| 92 | const SEARCH_PRISM: ToolDefinition = { |
| 93 | name: 'search_prism', |
| 94 | description: |
| 95 | 'Search the research store by meaning and keywords within the filters, reranked for relevance. Returns at most 3 passages, each labeled with where it is from and a ref to cite; none when nothing is relevant enough.', |
| 96 | parameters: { |
| 97 | type: 'object', |
| 98 | properties: { |
| 99 | query: { type: 'string', description: 'What to find, in a few specific words.' }, |
| 100 | ticker: { type: 'string', description: 'One company, like FLWS.' }, |
| 101 | document: { |
| 102 | type: 'string', |
| 103 | description: 'One document by its id, like doc:notes.pdf: a file the user attached, or a page read.', |
| 104 | }, |
| 105 | form: { |
| 106 | type: 'string', |
| 107 | description: 'One form, like 10-K, 10-Q, 8-K, DEF 14A, or 4; its amendments are included.', |
| 108 | }, |
| 109 | fiscal_year: { type: 'integer', description: "The company's fiscal year, as its 10-K or 10-Q says it." }, |
| 110 | fiscal_period: { type: 'string', description: 'FY for a 10-K, Q1, Q2, or Q3 for a 10-Q.' }, |
| 111 | section: { |
| 112 | type: 'string', |
| 113 | description: |
| 114 | "A section by its item or name: Item 1A or Risk Factors, Item 7 or Management's Discussion and Analysis, Item 2.02, EX-99.1.", |
| 115 | }, |
| 116 | filed_after: { type: 'string', description: 'YYYY-MM-DD, filed on or after.' }, |
| 117 | filed_before: { type: 'string', description: 'YYYY-MM-DD, filed before.' }, |
| 118 | }, |
| 119 | required: ['query'], |
| 120 | }, |
| 121 | } |
| 122 | |
| 123 | const SEARCH_WEB: ToolDefinition = { |
| 124 | name: 'search_web', |
| 125 | description: |
| 126 | 'Search the web for pages: their titles, addresses, and a line from each. Read one with fetch_page to cite it; a search result alone is not a passage.', |
| 127 | parameters: { |
| 128 | type: 'object', |
| 129 | properties: { query: { type: 'string', description: 'What to find, in a few specific words.' } }, |
| 130 | required: ['query'], |
| 131 | }, |
| 132 | } |
| 133 | |
| 134 | const ADD_COMPANY: ToolDefinition = { |
| 135 | name: 'add_company', |
| 136 | description: |
| 137 | "Read the kinds of a public company's SEC filings a question needs, filed in the years from to to, into the store. Read only what the question needs (risk factors are reports, not insiders' trades); another kind or year can be added when a later question needs it. Takes a minute or more; the filings are searchable once it answers.", |
| 138 | parameters: { |
| 139 | type: 'object', |
| 140 | properties: { |
| 141 | ticker: { |
| 142 | type: 'string', |
| 143 | description: 'Its ticker, like GT; for one SEC no longer lists by ticker, TICKER:CIK, like DFS:1393612.', |
| 144 | }, |
| 145 | from: { type: 'integer', description: 'The first year filed, like 2024.' }, |
| 146 | to: { type: 'integer', description: 'The last year filed, like 2025.' }, |
| 147 | categories: { |
| 148 | type: 'array', |
| 149 | items: { type: 'string', enum: CATEGORY_NAMES }, |
| 150 | description: `The kinds of filing to read, each with its amendments: ${CATEGORY_NAMES.map((c) => `${c}, ${CATEGORIES[c].holds} (${CATEGORIES[c].forms.join(', ')})`).join('; ')}.`, |
| 151 | }, |
| 152 | }, |
| 153 | required: ['ticker', 'from', 'to', 'categories'], |
| 154 | }, |
| 155 | } |
| 156 | |
| 157 | const CALCULATE: ToolDefinition = { |
| 158 | name: 'calculate', |
| 159 | description: |
| 160 | 'Work out arithmetic exactly. One line each, `name = expression`, a line using the names above it. Numbers without commas, + - * / ^, parentheses, and abs, sqrt, ln, log10, exp, round(x, decimals), min, max, sum, avg, npv(rate, cf0, cf1, ...) with cf0 at time zero, irr(cf0, cf1, ...). Answers a passage to cite for the figures it works out.', |
| 161 | parameters: { |
| 162 | type: 'object', |
| 163 | properties: { |
| 164 | expressions: { |
| 165 | type: 'string', |
| 166 | description: 'Lines like `rev = 2994105`, `cost = 1427546 - 67032`, `margin = (rev - cost) / rev`.', |
| 167 | }, |
| 168 | }, |
| 169 | required: ['expressions'], |
| 170 | }, |
| 171 | } |
| 172 | |
| 173 | const FETCH_PAGE: ToolDefinition = { |
| 174 | name: 'fetch_page', |
| 175 | description: |
| 176 | 'Read a public web page into the research store and get the 3 passages of it most relevant to query. The page is kept, so search_prism finds it later.', |
| 177 | parameters: { |
| 178 | type: 'object', |
| 179 | properties: { |
| 180 | url: { type: 'string', description: 'A full https address.' }, |
| 181 | query: { type: 'string', description: 'What to find on the page, in a few specific words.' }, |
| 182 | }, |
| 183 | required: ['url', 'query'], |
| 184 | }, |
| 185 | } |
| 186 | |
| 187 | export async function runAgent( |
| 188 | ctx: RunnerContext, |
| 189 | agent: Agent, |
| 190 | message: AgentMessage, |
| 191 | firstTurn: string, |
| 192 | save: (message: AgentMessage) => Promise<void>, |
| 193 | signal: AbortSignal, |
| 194 | ): Promise<AgentMessage> { |
| 195 | const turns: Turn[] = [{ role: 'user', text: firstTurn }] |
| 196 | let current: AgentMessage = { ...message, status: 'working' } |
| 197 | let refs = 0 |
| 198 | /** The answer goes back once to cite what it did not; what it still does not is said under it. */ |
| 199 | let repaired = false |
| 200 | const nextRef = (): string => `p${++refs}` |
| 201 | /** The model's latest note, kept on the working line until it writes another. */ |
| 202 | let thought: string | null = null |
| 203 | const working = async (step: string, company?: string): Promise<void> => { |
| 204 | current = { ...current, working: { step, thought, ...(company ? { company } : {}) } } |
| 205 | await save(current) |
| 206 | } |
| 207 | try { |
| 208 | // Only the skills it was given and the app has: one the user removed since is left out. |
| 209 | const skills = agent.skills.length > 0 ? await ctx.skills.catalog({ names: agent.skills }) : [] |
| 210 | const system = [systemPrompt(agent, await ctx.holdings(signal)), skillsPrompt(skills)].filter(Boolean).join('\n\n') |
| 211 | const offered = [ |
| 212 | ...(agent.web ? [SEARCH_PRISM, FETCH_PAGE, SEARCH_WEB] : [SEARCH_PRISM]), |
| 213 | ADD_COMPANY, |
| 214 | CALCULATE, |
| 215 | ...skillTools(skills.map((s) => s.name)), |
| 216 | ] |
| 217 | for (;;) { |
| 218 | await working(repaired ? 'Adding citations…' : 'Thinking…') |
| 219 | const reply = await ctx.llm.complete({ system, turns, tools: offered, model: agent.model ?? undefined, signal }) |
| 220 | current = { |
| 221 | ...current, |
| 222 | usage: { input: current.usage.input + reply.usage.input, output: current.usage.output + reply.usage.output }, |
| 223 | } |
| 224 | turns.push({ role: 'assistant', text: reply.text, toolCalls: reply.toolCalls, raw: reply.raw }) |
| 225 | if (reply.toolCalls.length === 0) { |
| 226 | const text = reply.text.trim() |
| 227 | const given = current.searches.flatMap((s) => s.passages.map((p) => p.ref)) |
| 228 | const missing = uncited(text) |
| 229 | const unknown = unknownRefs(text, new Set(given)) |
| 230 | if (!repaired && (missing.length > 0 || unknown.length > 0)) { |
| 231 | repaired = true |
| 232 | turns.push({ role: 'user', text: repairText(missing, unknown, given) }) |
| 233 | current = { ...current, text } |
| 234 | await save(current) |
| 235 | continue |
| 236 | } |
| 237 | const audit = unknown.length > 0 ? { unknown } : null |
| 238 | current = { ...current, status: 'done', text, audit } |
| 239 | break |
| 240 | } |
| 241 | thought = thoughtOf(reply.text, reply.raw) ?? thought |
| 242 | const results: { callId: string; output: string; isError: boolean }[] = [] |
| 243 | for (const call of reply.toolCalls) { |
| 244 | await working(stepOf(call)) |
| 245 | if (!offered.some((tool) => tool.name === call.name)) { |
| 246 | results.push({ |
| 247 | callId: call.id, |
| 248 | output: `There is no tool ${call.name}; the tools are ${offered.map((tool) => tool.name).join(' and ')}.`, |
| 249 | isError: true, |
| 250 | }) |
| 251 | continue |
| 252 | } |
| 253 | const skill = await runSkillTool(ctx.skills, call, turns) |
| 254 | if (skill) { |
| 255 | const use: SkillUse = { |
| 256 | skill: String(call.input[call.name === ACTIVATE_SKILL ? 'name' : 'skill'] ?? ''), |
| 257 | file: call.name === ACTIVATE_SKILL ? null : String(call.input['path'] ?? ''), |
| 258 | error: skill.isError ? skill.output : null, |
| 259 | } |
| 260 | current = { ...current, skills: [...(current.skills ?? []), use] } |
| 261 | results.push(skill) |
| 262 | continue |
| 263 | } |
| 264 | const query = typeof call.input['query'] === 'string' ? call.input['query'].trim() : '' |
| 265 | const { search, output } = |
| 266 | call.name === CALCULATE.name |
| 267 | ? runCalculation(String(call.input['expressions'] ?? ''), nextRef) |
| 268 | : call.name === ADD_COMPANY.name |
| 269 | ? await runAddCompany(ctx, call.input, signal, working) |
| 270 | : call.name === SEARCH_WEB.name |
| 271 | ? await runWebSearch(ctx, query, signal) |
| 272 | : withPassages( |
| 273 | call.name === FETCH_PAGE.name |
| 274 | ? await runFetch(ctx, agent, String(call.input['url'] ?? ''), query, nextRef, signal) |
| 275 | : await runSearch(ctx, agent, query, call.input, nextRef, signal, working), |
| 276 | ) |
| 277 | current = { ...current, searches: [...current.searches, search] } |
| 278 | results.push({ callId: call.id, output: search.error ?? output, isError: search.error !== null }) |
| 279 | } |
| 280 | turns.push({ role: 'tool', results }) |
| 281 | await save(current) |
| 282 | } |
| 283 | } catch (err) { |
| 284 | current = signal.aborted |
| 285 | ? { ...current, status: 'stopped', error: errorText(signal.reason, 'Stopped.') } |
| 286 | : { ...current, status: 'error', error: errorText(err, 'failed') } |
| 287 | } |
| 288 | current = { ...current, working: null } |
| 289 | await save(current) |
| 290 | return current |
| 291 | } |
| 292 | |
| 293 | /** A search of the store or a page, as the model reads it: its passages. */ |
| 294 | function withPassages(search: Search): { search: Search; output: string } { |
| 295 | return { search, output: passagesText(search.passages) } |
| 296 | } |
| 297 | |
| 298 | /** The lines worked out, as one passage to cite; a calculation with no value at all is an error. */ |
| 299 | function runCalculation(expressions: string, nextRef: () => string): { search: Search; output: string } { |
| 300 | const worked = calculate(expressions) |
| 301 | const query = expressions.trim() |
| 302 | if (worked.lines.every((l) => l.error !== null)) { |
| 303 | return { search: { query, tool: 'calculate', passages: [], error: worked.text }, output: worked.text } |
| 304 | } |
| 305 | const ref = nextRef() |
| 306 | const passage: Passage = { ref, id: calculationId(ref), label: 'Calculation', url: null, score: 1, text: worked.text } |
| 307 | return { search: { query, tool: 'calculate', passages: [passage], error: null }, output: passagesText([passage]) } |
| 308 | } |
| 309 | |
| 310 | /** A company's filings read into the store and embedded for the years asked, before the model searches them. */ |
| 311 | async function runAddCompany( |
| 312 | ctx: RunnerContext, |
| 313 | input: Record<string, unknown>, |
| 314 | signal: AbortSignal, |
| 315 | progress: (step: string, company?: string) => Promise<void>, |
| 316 | ): Promise<{ search: Search; output: string }> { |
| 317 | const ticker = String(input['ticker'] ?? '').trim() |
| 318 | const from = Number(input['from']) |
| 319 | const to = Number(input['to']) |
| 320 | const asked = Array.isArray(input['categories']) ? input['categories'].map(String) : [] |
| 321 | const categories = CATEGORY_NAMES.filter((c) => asked.includes(c)) |
| 322 | const years = |
| 323 | Number.isInteger(from) && Number.isInteger(to) && from <= to |
| 324 | ? Array.from({ length: to - from + 1 }, (_, i) => from + i) |
| 325 | : [] |
| 326 | const query = `${ticker} ${readsText(Object.fromEntries(categories.map((c) => [c, years]))) || `${from}–${to}`}` |
| 327 | let named: string | undefined |
| 328 | const failed = (error: string) => ({ |
| 329 | search: { query, tool: 'add_company' as const, ...(named ? { ticker: named } : {}), passages: [], error }, |
| 330 | output: '', |
| 331 | }) |
| 332 | try { |
| 333 | named = tickerOf(ticker) |
| 334 | } catch (err) { |
| 335 | return failed(errorText(err, 'that is not a ticker')) |
| 336 | } |
| 337 | if (years.length === 0) return failed('from and to are years, from first.') |
| 338 | const unknown = asked.filter((c) => !(CATEGORY_NAMES as string[]).includes(c)) |
| 339 | if (categories.length === 0 || unknown.length > 0) { |
| 340 | return failed(`categories are one or more of ${CATEGORY_NAMES.join(', ')}.`) |
| 341 | } |
| 342 | try { |
| 343 | // The working line names the company while it is read in, so the view lists it from the start. |
| 344 | const company = named |
| 345 | await progress(`Reading ${company} filings`, company) |
| 346 | const said = await ctx.addCompany(ticker, years, categories, signal, (step) => progress(step, company)) |
| 347 | return { search: { query, tool: 'add_company', ticker: named, passages: [], error: null }, output: said } |
| 348 | } catch (err) { |
| 349 | return failed(errorText(err, 'the filings could not be read')) |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | /** Pages to read, not passages: the model fetches one to cite it. */ |
| 354 | async function runWebSearch( |
| 355 | ctx: RunnerContext, |
| 356 | query: string, |
| 357 | signal: AbortSignal, |
| 358 | ): Promise<{ search: Search; output: string }> { |
| 359 | if (!query) return { search: { query, tool: 'search_web', passages: [], error: 'query is empty.' }, output: '' } |
| 360 | try { |
| 361 | const found = await ctx.searchWeb(query, signal) |
| 362 | const output = |
| 363 | found.length === 0 |
| 364 | ? 'The web search found nothing. Change the words.' |
| 365 | : found.map((r) => `${r.title}\n${r.url}\n${r.snippet}`).join('\n\n') |
| 366 | const results = found.map((r) => ({ title: r.title, url: r.url })) |
| 367 | return { search: { query, tool: 'search_web', results, passages: [], error: null }, output } |
| 368 | } catch (err) { |
| 369 | return { |
| 370 | search: { query, tool: 'search_web', passages: [], error: errorText(err, 'the web search failed') }, |
| 371 | output: '', |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | /** A page read in, then searched alone: the agent's other holds are about what it had, not this page. */ |
| 377 | async function runFetch( |
| 378 | ctx: RunnerContext, |
| 379 | agent: Agent, |
| 380 | url: string, |
| 381 | query: string, |
| 382 | nextRef: () => string, |
| 383 | signal: AbortSignal, |
| 384 | ): Promise<Search> { |
| 385 | if (!query) return { query, url, passages: [], error: 'query is empty.' } |
| 386 | let page: { url: string; doc: string } |
| 387 | try { |
| 388 | page = await ctx.fetchPage(url.trim(), signal) |
| 389 | } catch (err) { |
| 390 | return { query, url, passages: [], error: errorText(err, 'the page could not be read') } |
| 391 | } |
| 392 | const search = await held(ctx, { query, documents: [page.doc] }, query, nextRef, signal) |
| 393 | return { ...search, url: page.url } |
| 394 | } |
| 395 | |
| 396 | async function runSearch( |
| 397 | ctx: RunnerContext, |
| 398 | agent: Agent, |
| 399 | query: string, |
| 400 | input: Record<string, unknown>, |
| 401 | nextRef: () => string, |
| 402 | signal: AbortSignal, |
| 403 | progress: (step: string, company?: string) => Promise<void>, |
| 404 | ): Promise<Search> { |
| 405 | if (!query) return { query, passages: [], error: 'query is empty.' } |
| 406 | const merged = mergeSearch(query, agent.search, askedOf(input)) |
| 407 | if ('refused' in merged) return { query, passages: [], error: merged.refused } |
| 408 | const tickers = merged.args['tickers'] |
| 409 | for (const ticker of Array.isArray(tickers) ? tickers.map(String) : []) { |
| 410 | try { |
| 411 | await ctx.keepCurrent(ticker, signal, (step) => progress(step, ticker)) |
| 412 | } catch (err) { |
| 413 | if (signal.aborted) throw err |
| 414 | // SEC out of reach is no reason to refuse a search of what the store already holds. |
| 415 | console.warn(`${ticker}: ${errorText(err, 'could not be checked for new filings')}`) |
| 416 | } |
| 417 | } |
| 418 | return held(ctx, merged.args, query, nextRef, signal) |
| 419 | } |
| 420 | |
| 421 | /** One search of the store, its hits as passages with refs that run on across the answer. */ |
| 422 | async function held( |
| 423 | ctx: RunnerContext, |
| 424 | args: Record<string, unknown>, |
| 425 | query: string, |
| 426 | nextRef: () => string, |
| 427 | signal: AbortSignal, |
| 428 | ): Promise<Search> { |
| 429 | const { query: _words, ...filters } = args |
| 430 | try { |
| 431 | const hits = await ctx.search(args, signal) |
| 432 | const passages: Passage[] = hits.map((hit) => ({ |
| 433 | ref: nextRef(), |
| 434 | id: hit.id, |
| 435 | label: hit.label, |
| 436 | url: hit.url, |
| 437 | score: hit.score, |
| 438 | text: hit.text, |
| 439 | })) |
| 440 | return { query, filters, passages, error: null } |
| 441 | } catch (err) { |
| 442 | return { query, filters, passages: [], error: errorText(err, 'search failed') } |
| 443 | } |
| 444 | } |