Research Agents: Lets users define research agents that answer chat questions from SEC filings, uploaded documents, and the web, with every figure cited.
| 1 | import { defineConnection, definePlugin, defineSource, defineView } from '@jaspers-ai/sdk' |
| 2 | import { z } from 'zod' |
| 3 | import { deleteAgent, publishAgents, saveAgent, watchAgents } from './agent-files' |
| 4 | import { attach, openConversation, post, stop } from './chats' |
| 5 | import { deleteDoc, uploadDoc } from './doc-files' |
| 6 | import { AgentsView } from './AgentsView' |
| 7 | import { research, sectionText, type Outline } from './research' |
| 8 | import { refresh } from './sec-files' |
| 9 | import { watchStatus } from './status' |
| 10 | |
| 11 | // Research agents: agents the user defines, each a Markdown file of instructions and the companies, |
| 12 | // documents, and forms its searches are held to, and a chat where the user tags them. An agent answers |
| 13 | // from the research store (JaspersAI/research-store, the plugin's connection agents/research), SEC |
| 14 | // filings cut by their sections and the account's own documents, handed at most three reranked |
| 15 | // passages a search and never a whole document. The view is the whole interface: every source is the view's. |
| 16 | |
| 17 | // What an agent's searches are held to, as the editor sends it (AgentSearch). |
| 18 | const Search = z.object({ |
| 19 | tickers: z.array(z.string()).optional(), |
| 20 | documents: z.array(z.string()).optional(), |
| 21 | forms: z.array(z.string()).optional(), |
| 22 | origins: z.array(z.string()).optional(), |
| 23 | since: z.string().nullable().optional(), |
| 24 | }) |
| 25 | |
| 26 | const saveAgentSource = defineSource({ |
| 27 | description: 'Create or change an agent, saved as defs/<id>.md.', |
| 28 | input: z.object({ |
| 29 | id: z.string(), |
| 30 | name: z.string(), |
| 31 | description: z.string().optional(), |
| 32 | model: z.string().nullable().optional(), |
| 33 | color: z.string().nullable().optional(), |
| 34 | web: z.boolean().optional(), |
| 35 | skills: z.array(z.string()).optional(), |
| 36 | search: Search.optional(), |
| 37 | instructions: z.string(), |
| 38 | }), |
| 39 | internal: true, |
| 40 | run: async (args, ctx) => { |
| 41 | await saveAgent(ctx, args) |
| 42 | await publishAgents(ctx) |
| 43 | return `Saved ${args.name}.` |
| 44 | }, |
| 45 | }) |
| 46 | |
| 47 | const skillsSource = defineSource({ |
| 48 | description: "The app's skills an agent may be given, by name and what each is for.", |
| 49 | input: z.object({}), |
| 50 | internal: true, |
| 51 | ttlMs: 0, |
| 52 | run: async (_args, ctx) => |
| 53 | JSON.stringify( |
| 54 | (await ctx.skills.catalog()).map((s) => ({ name: s.name, description: s.description, plugin: s.plugin })), |
| 55 | ), |
| 56 | }) |
| 57 | |
| 58 | const deleteAgentSource = defineSource({ |
| 59 | description: "Delete an agent's file.", |
| 60 | input: z.object({ id: z.string() }), |
| 61 | internal: true, |
| 62 | run: async (args, ctx) => { |
| 63 | await deleteAgent(ctx, args.id) |
| 64 | await publishAgents(ctx) |
| 65 | return `Deleted ${args.id}.` |
| 66 | }, |
| 67 | }) |
| 68 | |
| 69 | const documentSource = defineSource({ |
| 70 | description: |
| 71 | "A document for its window: its outline, and the cited chunk's section whole, with the chunk's place in it.", |
| 72 | input: z.object({ doc: z.string(), chunk: z.number().int().optional() }), |
| 73 | internal: true, |
| 74 | ttlMs: 0, |
| 75 | run: async (args, ctx) => { |
| 76 | const outline = await research<Outline>(ctx, 'document_outline', { doc_id: args.doc, chunk_id: args.chunk ?? null }) |
| 77 | const open = outline.chunk?.section ?? outline.sections[0]?.seq |
| 78 | const text = open === undefined ? '' : await sectionText(ctx, args.doc, open) |
| 79 | return JSON.stringify({ ...outline, open, text }) |
| 80 | }, |
| 81 | }) |
| 82 | |
| 83 | const documentSectionSource = defineSource({ |
| 84 | description: 'One section of a document, whole, for its window.', |
| 85 | input: z.object({ doc: z.string(), seq: z.number().int() }), |
| 86 | internal: true, |
| 87 | ttlMs: 0, |
| 88 | run: async (args, ctx) => sectionText(ctx, args.doc, args.seq), |
| 89 | }) |
| 90 | |
| 91 | const CONVERSATION = z.string().describe("A conversation id, as a panel's state names it.") |
| 92 | |
| 93 | const openConversationSource = defineSource({ |
| 94 | description: |
| 95 | "Publish a panel's conversation for the view: the one asked for while it is kept, else a new one. Answers its id.", |
| 96 | input: z.object({ id: z.string().optional() }), |
| 97 | internal: true, |
| 98 | ttlMs: 0, |
| 99 | run: async (args, ctx) => openConversation(ctx, args.id), |
| 100 | }) |
| 101 | |
| 102 | const postSource = defineSource({ |
| 103 | description: |
| 104 | "Ask the research agents in a Research agents panel already open: conversation is that panel's state.conversation, and text is the question, starting with @id when the user named an agent, like @fundamentals-analyst (the panel's summary lists them). Untagged, it goes to the agent asked last, else the first. Their answers appear in the panel.", |
| 105 | input: z.object({ conversation: CONVERSATION, text: z.string() }), |
| 106 | run: async (args, ctx) => post(ctx, args.conversation, args.text), |
| 107 | }) |
| 108 | |
| 109 | const stopSource = defineSource({ |
| 110 | description: "Stop the answers a conversation's agents are writing.", |
| 111 | input: z.object({ conversation: CONVERSATION }), |
| 112 | internal: true, |
| 113 | run: async (args, ctx) => stop(ctx, args.conversation), |
| 114 | }) |
| 115 | |
| 116 | const uploadSource = defineSource({ |
| 117 | description: |
| 118 | "Put a document's text in the research store, the account's own; the same name again replaces it. With a conversation, it is noted there as attached, so the next message there is answered knowing it.", |
| 119 | input: z.object({ |
| 120 | name: z.string(), |
| 121 | text: z.string(), |
| 122 | conversation: z.string().optional(), |
| 123 | }), |
| 124 | internal: true, |
| 125 | run: async ({ conversation, ...upload }, ctx) => { |
| 126 | const { id, title, said } = await uploadDoc(ctx, upload) |
| 127 | if (conversation) await attach(ctx, conversation, id, title) |
| 128 | return said |
| 129 | }, |
| 130 | }) |
| 131 | |
| 132 | const deleteDocSource = defineSource({ |
| 133 | description: 'Delete one of your documents and its chunks.', |
| 134 | input: z.object({ id: z.string() }), |
| 135 | internal: true, |
| 136 | run: async (args, ctx) => deleteDoc(ctx, args.id), |
| 137 | }) |
| 138 | |
| 139 | const refreshSource = defineSource({ |
| 140 | description: 'Read what a company has filed since the store last listed it, in the kinds of filing it keeps current.', |
| 141 | input: z.object({ ticker: z.string() }), |
| 142 | internal: true, |
| 143 | run: async (args, ctx) => refresh(ctx, args.ticker), |
| 144 | }) |
| 145 | |
| 146 | /** Where the research store is. (The manifest is read in a sandbox with no `process`: a constant, not an environment variable.) */ |
| 147 | const STORE_URL = 'https://research-store.jsprai.com/mcp' |
| 148 | |
| 149 | export default definePlugin({ |
| 150 | id: 'agents', |
| 151 | connections: { |
| 152 | // The research store (JaspersAI/research-store): a Jaspers service the Terminal signs in to as the |
| 153 | // account. Internal: the assistant asks through the view and the agents do the reading, so it is |
| 154 | // never offered the store's tools. |
| 155 | research: defineConnection({ url: STORE_URL, auth: 'oauth', internal: true }), |
| 156 | }, |
| 157 | capabilities: ['llm', 'files', 'tools', 'skills', 'web', 'microphone'], |
| 158 | async start(ctx) { |
| 159 | await publishAgents(ctx) |
| 160 | watchAgents(ctx) |
| 161 | // The documents are the store's: status.ts lists them once it answers, and after every change. |
| 162 | watchStatus(ctx) |
| 163 | }, |
| 164 | sources: { |
| 165 | 'save-agent': saveAgentSource, |
| 166 | 'delete-agent': deleteAgentSource, |
| 167 | skills: skillsSource, |
| 168 | document: documentSource, |
| 169 | 'document-section': documentSectionSource, |
| 170 | 'open-conversation': openConversationSource, |
| 171 | post: postSource, |
| 172 | stop: stopSource, |
| 173 | upload: uploadSource, |
| 174 | 'delete-doc': deleteDocSource, |
| 175 | refresh: refreshSource, |
| 176 | }, |
| 177 | views: { |
| 178 | chat: defineView(AgentsView, { |
| 179 | title: 'Research agents', |
| 180 | state: z.object({ |
| 181 | conversation: z.string().optional().describe("This panel's conversation; a panel placed anew starts one."), |
| 182 | ask: z |
| 183 | .string() |
| 184 | .optional() |
| 185 | .describe( |
| 186 | 'A message to post in this panel as it opens, tagging the agents it is for with @id, like "@fundamentals-analyst Give me an overview of OPCH."', |
| 187 | ), |
| 188 | }), |
| 189 | output: z.object({ agents: z.array(z.string()), messages: z.number() }), |
| 190 | instructions: |
| 191 | "The user's research agents and one conversation in this panel where they are asked. To ask them something, place this view with state { ask } (or, in a panel already open, run agents/post with its conversation): the user's question, starting with @id when the user named an agent (the summary lists them). Untagged, it goes to the first agent, and with none one is made. That is all there is to do: the agents find and read the filings they need and answer in the panel, so look nothing up for them, do not wait for or read their answer, and never answer for them.", |
| 192 | summarize: (_state: unknown, output: { agents: string[]; messages: number } | null) => |
| 193 | `Agents: ${(output?.agents ?? []).map((a) => `@${a}`).join(', ') || 'none'} · ${output?.messages ?? 0} messages`, |
| 194 | }), |
| 195 | }, |
| 196 | }) |