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 | import type { BackendContext } from '@jaspers-ai/sdk' |
| 2 | import { loadAgents, publishAgents, saveAgent } from './agent-files.ts' |
| 3 | import { firstAgent, type Agent } from './agents.ts' |
| 4 | import { savePage } from './doc-files.ts' |
| 5 | import { |
| 6 | companiesUsed, |
| 7 | firstTurn, |
| 8 | interrupted, |
| 9 | newConversationId, |
| 10 | recipients, |
| 11 | tagged, |
| 12 | type AgentMessage, |
| 13 | type Chat, |
| 14 | type UserMessage, |
| 15 | } from './chat.ts' |
| 16 | import { errorText } from './errors.ts' |
| 17 | import { research, type Hit } from './research.ts' |
| 18 | import { companies, keepCurrent, readCompany } from './sec-files.ts' |
| 19 | import { runAgent, type RunnerContext } from './runner.ts' |
| 20 | |
| 21 | // The conversations: each kept as chats/<id>.json in the plugin's data folder and held here once read. |
| 22 | // A view shows one conversation, the one its panel names, and a panel placed anew starts a new one; |
| 23 | // chat/<id> is the live value it shows. A message starts one job per agent it goes to, keyed |
| 24 | // <id>/<seq>, which writes the agent's answer into its conversation as it grows. |
| 25 | |
| 26 | const FOLDER = 'chats' |
| 27 | const chats = new Map<string, Chat>() |
| 28 | /** Writes go one after another, so a later state never lands under an earlier one. */ |
| 29 | let writes: Promise<void> = Promise.resolve() |
| 30 | |
| 31 | async function load(ctx: BackendContext, id: string): Promise<Chat> { |
| 32 | const held = chats.get(id) |
| 33 | if (held) return held |
| 34 | const chat = interrupted(JSON.parse(await ctx.files.read(`${FOLDER}/${id}.json`)) as Chat) |
| 35 | // Another call may have loaded it while this one read. |
| 36 | const raced = chats.get(id) |
| 37 | if (raced) return raced |
| 38 | chats.set(id, chat) |
| 39 | return chat |
| 40 | } |
| 41 | |
| 42 | function commit(ctx: BackendContext, id: string, chat: Chat): void { |
| 43 | chats.set(id, chat) |
| 44 | ctx.live.set(`chat/${id}`, chat) |
| 45 | writes = writes |
| 46 | .then(() => ctx.files.write(`${FOLDER}/${id}.json`, JSON.stringify(chat))) |
| 47 | .catch((err: unknown) => console.warn(`conversation ${id}: ${errorText(err, 'not saved')}`)) |
| 48 | } |
| 49 | |
| 50 | function checkId(id: string): string { |
| 51 | if (!/^[\w-]+$/.test(id)) throw new Error(`No conversation ${id}.`) |
| 52 | return id |
| 53 | } |
| 54 | |
| 55 | /** Publishes a panel's conversation and says which: the one it names while that is kept, else a new one. */ |
| 56 | export async function openConversation(ctx: BackendContext, wanted?: string): Promise<string> { |
| 57 | if (wanted && /^[\w-]+$/.test(wanted)) { |
| 58 | try { |
| 59 | ctx.live.set(`chat/${wanted}`, await load(ctx, wanted)) |
| 60 | return wanted |
| 61 | } catch { |
| 62 | // Its file is gone: the panel starts again. |
| 63 | } |
| 64 | } |
| 65 | const now = new Date().toISOString() |
| 66 | const id = newConversationId(Date.now(), Math.random()) |
| 67 | commit(ctx, id, { messages: [], lastTo: [], created: now, updated: now }) |
| 68 | return id |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Notes a file the user attached in a conversation, before the next message: whoever answers that |
| 73 | * message is told it was just attached, since attaching and then asking is asking about it. |
| 74 | */ |
| 75 | export async function attach(ctx: BackendContext, rawId: string, doc: string, name: string): Promise<void> { |
| 76 | const id = checkId(rawId) |
| 77 | const chat = await load(ctx, id) |
| 78 | const at = new Date().toISOString() |
| 79 | const before = (chat.messages.at(-1)?.seq ?? 0) + 1 |
| 80 | commit(ctx, id, { ...chat, attached: [...(chat.attached ?? []), { doc, name, at, before }], updated: at }) |
| 81 | } |
| 82 | |
| 83 | export async function post(ctx: BackendContext, rawId: string, text: string): Promise<string> { |
| 84 | const id = checkId(rawId) |
| 85 | const words = text.trim() |
| 86 | if (!words) throw new Error('Write a message.') |
| 87 | const agents = await usableAgents(ctx) |
| 88 | const chat = await load(ctx, id) |
| 89 | const to = recipients(words, agents, chat.lastTo) |
| 90 | const at = new Date().toISOString() |
| 91 | let seq = (chat.messages.at(-1)?.seq ?? 0) + 1 |
| 92 | const asked: UserMessage = { seq, role: 'user', text: words, to, at } |
| 93 | const answers: AgentMessage[] = to.map((agent) => ({ |
| 94 | seq: ++seq, |
| 95 | role: 'agent', |
| 96 | agent, |
| 97 | inReplyTo: asked.seq, |
| 98 | status: 'working', |
| 99 | text: '', |
| 100 | searches: [], |
| 101 | usage: { input: 0, output: 0 }, |
| 102 | error: null, |
| 103 | at, |
| 104 | })) |
| 105 | const named = tagged(words, agents) |
| 106 | commit(ctx, id, { |
| 107 | ...chat, |
| 108 | messages: [...chat.messages, asked, ...answers], |
| 109 | lastTo: named.length > 0 ? named : chat.lastTo, |
| 110 | updated: at, |
| 111 | }) |
| 112 | |
| 113 | const names = new Map(agents.map((a) => [a.id, a.name])) |
| 114 | const turn = firstTurn(chats.get(id)!.messages, asked.seq, names, chat.attached) |
| 115 | for (const answer of answers) { |
| 116 | const agent = agents.find((a) => a.id === answer.agent)! |
| 117 | ctx.jobs.start(`${id}/${answer.seq}`, async (signal) => { |
| 118 | await runAgent(runnerContext(ctx, id), agent, answer, turn, async (message) => replace(ctx, id, message), signal) |
| 119 | }) |
| 120 | } |
| 121 | return `Sent to ${to.map((a) => `@${a}`).join(', ')}.` |
| 122 | } |
| 123 | |
| 124 | /** The agents that read, one made first when there are none, so a message always has someone to answer it. */ |
| 125 | async function usableAgents(ctx: BackendContext): Promise<Agent[]> { |
| 126 | const all = await loadAgents(ctx) |
| 127 | const usable = all.filter((a) => a.error === null) |
| 128 | if (usable.length > 0) return usable |
| 129 | await saveAgent(ctx, firstAgent(all.map((a) => a.id))) |
| 130 | return (await publishAgents(ctx)).filter((a) => a.error === null) |
| 131 | } |
| 132 | |
| 133 | /** Stops every answer still being written in a conversation. */ |
| 134 | export function stop(ctx: BackendContext, rawId: string): string { |
| 135 | const prefix = `${checkId(rawId)}/` |
| 136 | const running = ctx.jobs.running().filter((key) => key.startsWith(prefix)) |
| 137 | for (const key of running) ctx.jobs.abort(key, 'Stopped.') |
| 138 | return running.length > 0 ? 'Stopped.' : 'Nothing was running.' |
| 139 | } |
| 140 | |
| 141 | function replace(ctx: BackendContext, id: string, message: AgentMessage): void { |
| 142 | const chat = chats.get(id) |
| 143 | // A message no longer in its conversation has nowhere to go. |
| 144 | const same = (m: Chat['messages'][number]): boolean => |
| 145 | m.seq === message.seq && m.role === 'agent' && m.agent === message.agent && m.at === message.at |
| 146 | if (!chat?.messages.some(same)) return |
| 147 | commit(ctx, id, { ...chat, messages: chat.messages.map((m) => (same(m) ? message : m)) }) |
| 148 | } |
| 149 | |
| 150 | /** The companies every conversation held here touches, each once: what the status poller asks the store about. */ |
| 151 | export function openTickers(): string[] { |
| 152 | const out = new Set<string>() |
| 153 | for (const chat of chats.values()) for (const ticker of companiesUsed(chat.messages)) out.add(ticker) |
| 154 | return [...out] |
| 155 | } |
| 156 | |
| 157 | function runnerContext(ctx: BackendContext, id: string): RunnerContext { |
| 158 | return { |
| 159 | llm: ctx.llm, |
| 160 | search: (args, signal) => research<Hit[]>(ctx, 'search', args, signal), |
| 161 | fetchPage: (url, signal) => savePage(ctx, url, signal), |
| 162 | searchWeb: (query, signal) => ctx.web.search(query, { signal }), |
| 163 | // The store holds every account's companies: the agent is told of the ones this conversation touches. |
| 164 | holdings: async (signal) => { |
| 165 | const held = await companies(ctx, companiesUsed(chats.get(id)?.messages ?? []), signal) |
| 166 | return held.map((c) => ({ ticker: c.ticker, name: c.company ?? c.ticker, reads: c.reads })) |
| 167 | }, |
| 168 | addCompany: (ticker, years, categories, signal, progress) => |
| 169 | readCompany(ctx, ticker, years, categories, signal, progress), |
| 170 | keepCurrent: (ticker, signal, progress) => keepCurrent(ctx, ticker, signal, progress), |
| 171 | skills: ctx.skills, |
| 172 | } |
| 173 | } |