Jaspers Hub

jaspers / agents

pluginOfficialReviewed★ 0↓ 0

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.

8.1 KB
1import { defineConnection, definePlugin, defineSource, defineView } from '@jaspers-ai/sdk'
2import { z } from 'zod'
3import { deleteAgent, publishAgents, saveAgent, watchAgents } from './agent-files'
4import { attach, openConversation, post, stop } from './chats'
5import { deleteDoc, uploadDoc } from './doc-files'
6import { AgentsView } from './AgentsView'
7import { research, sectionText, type Outline } from './research'
8import { refresh } from './sec-files'
9import { 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).
18const 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
26const 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
47const 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
58const 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
69const 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
83const 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
91const CONVERSATION = z.string().describe("A conversation id, as a panel's state names it.")
92
93const 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
102const 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
109const 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
116const 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
132const 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
139const 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.) */
147const STORE_URL = 'https://research-store.jsprai.com/mcp'
148
149export 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})