Jaspers Hub

jaspers / agents

pluginOfficialReviewed★ 0↓ 1

Research Agents: Lets users define research agents that answer chat questions from SEC filings, uploaded documents, and the web, with every figure cited.

18 KB
1// The app admits only the SDK's root to a plugin, so the skill helpers come from there.
2import { ACTIVATE_SKILL, runSkillTool, skillsPrompt, skillTools, type Skills } from '@jaspers-ai/sdk'
3import type { Agent } from './agents.ts'
4import {
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'
14import { CATEGORIES, CATEGORY_NAMES, readsText, tickerOf, type Category } from './sec.ts'
15import { stepOf, thoughtOf } from './working.ts'
16import { repairText, uncited, unknownRefs } from './cite.ts'
17import { errorText } from './errors.ts'
18import type { Hit } from './research.ts'
19import { askedOf, mergeSearch } from './search.ts'
20import { 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
31export interface ToolDefinition {
32 name: string
33 description: string
34 parameters: Record<string, unknown>
35}
36
37export interface ToolCall {
38 id: string
39 name: string
40 input: Record<string, unknown>
41}
42
43export 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
48export 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. */
56export 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
92const 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
123const 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
134const 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
157const 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
173const 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
187export 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. */
294function 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. */
299function 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. */
311async 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. */
354async 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. */
377async 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
396async 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. */
422async 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}