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.

9.3 KB
1import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
2
3// Agents are files: frontmatter saying who one is and what its searches of the store are held to, then the
4// instructions it works by. This is the reading and the writing of those files, pure so the format
5// can be read in a test. A file that breaks a rule still becomes an agent, carrying the error, so the
6// list can show the user what to fix.
7
8/**
9 * What every one of an agent's searches is held to, whatever the model asks. Every agent reads every
10 * company; its documents, when it names any, are its scope among the rest.
11 */
12export interface AgentSearch {
13 /** Documents, by id (`doc:<name>`), it reads. */
14 documents: string[]
15 /** Forms it reads (10-K, 8-K, 4, ...), each with its amendments; empty for any. */
16 forms: string[]
17 /** What kinds of document it reads: sec, upload, web; empty for any. */
18 origins: string[]
19 /** An ISO date; what was filed or added before it is left out. Null for no bound. */
20 since: string | null
21}
22
23export interface Agent {
24 id: string
25 name: string
26 description: string
27 /** A model on the configured provider; null for the app's own. */
28 model: string | null
29 /** #rrggbb for its square, or null. */
30 color: string | null
31 /** Whether it may read public web pages into the store with fetch_page. */
32 web: boolean
33 /** The app's skills it may load, by name (dcf, or research:dcf for a plugin's); none when empty. */
34 skills: string[]
35 search: AgentSearch
36 instructions: string
37 /** Why the file is not usable, worded for the user; null when it is. */
38 error: string | null
39}
40
41/** What save-agent writes: an agent without what reading one adds. */
42export interface AgentInput {
43 id: string
44 name: string
45 description?: string
46 model?: string | null
47 color?: string | null
48 web?: boolean
49 skills?: string[]
50 search?: Partial<AgentSearch>
51 instructions: string
52}
53
54/** The most passages one search hands the model: the store's own limit, said here for the prompt. */
55export const CHUNKS = 3
56export const ORIGINS = ['sec', 'upload', 'web']
57
58export const AGENT_ID = /^[a-z0-9][a-z0-9-]*$/
59/**
60 * An agent's id from its name, which is what the user tags it with: lower case, accents dropped,
61 * anything else between words a dash (Documents Analyst is documents-analyst), and a number after
62 * it when another agent has it already. A name with nothing to keep is agent.
63 */
64export function agentIdFrom(name: string, taken: string[]): string {
65 const base =
66 name
67 .normalize('NFKD')
68 .replace(/[\u0300-\u036f]/g, '')
69 .toLowerCase()
70 .replace(/[^a-z0-9]+/g, '-')
71 .replace(/^-+|-+$/g, '') || 'agent'
72 if (!taken.includes(base)) return base
73 let n = 2
74 while (taken.includes(`${base}-${n}`)) n++
75 return `${base}-${n}`
76}
77
78/** The agent made for a message posted while there is none, so a question is never turned away. */
79export function firstAgent(taken: string[]): AgentInput {
80 const name = 'Research Analyst'
81 return {
82 id: agentIdFrom(name, taken),
83 name,
84 description: "Researches companies from their filings and the user's documents.",
85 instructions:
86 'You research companies for the user from their SEC filings, the documents they upload, and the web. Answer what was asked, with the figures and quotes that back it, and say plainly what the sources do not cover.',
87 }
88}
89
90export const INSTRUCTIONS_MAX = 10_000
91const NAME_MAX = 80
92const DESCRIPTION_MAX = 500
93const MODEL_MAX = 200
94const COLOR = /^#[0-9a-fA-F]{6}$/
95const FIELDS = ['name', 'description', 'model', 'color', 'web', 'skills', 'documents', 'form', 'origin', 'since']
96/** The frontmatter: a --- line, anything, and the next line that is only ---. */
97const FRONTMATTER = /^---\n([\s\S]*?)^---[ \t]*$/m
98
99export function emptySearch(): AgentSearch {
100 return { documents: [], forms: [], origins: [], since: null }
101}
102
103export function parseAgent(id: string, text: string): Agent {
104 const agent: Agent = {
105 id,
106 name: id,
107 description: '',
108 model: null,
109 color: null,
110 web: false,
111 skills: [],
112 search: emptySearch(),
113 instructions: '',
114 error: null,
115 }
116 try {
117 if (!AGENT_ID.test(id)) throw new Error('the file name has to be lower case letters, digits, and dashes, then .md')
118 const normalized = text.replace(/^/, '').replace(/\r\n?/g, '\n')
119 const match = FRONTMATTER.exec(normalized)
120 if (!match || match.index !== 0)
121 throw new Error('the file has to start with a --- line, then the frontmatter, then a closing --- line')
122 const data = frontmatter(match[1] ?? '')
123 const unknown = Object.keys(data).filter((key) => !FIELDS.includes(key))
124 if (unknown.length > 0) throw new Error(`unknown field ${unknown.join(', ')}; the fields are ${FIELDS.join(', ')}`)
125 agent.name = textField(data['name'], 'name', NAME_MAX, true)
126 agent.description = textField(data['description'], 'description', DESCRIPTION_MAX, false)
127 agent.model = textField(data['model'], 'model', MODEL_MAX, false) || null
128 agent.color = color(data['color'])
129 agent.web = flag(data['web'], 'web')
130 agent.skills = names(data['skills'], 'skills')
131 agent.search = {
132 documents: names(data['documents'], 'documents'),
133 forms: names(data['form'], 'form').map((f) => f.toUpperCase()),
134 origins: origins(data['origin']),
135 since: since(data['since']),
136 }
137 const body = normalized.slice(match[0].length).trim()
138 if (body === '') throw new Error('the instructions, the text after the frontmatter, are empty')
139 if (body.length > INSTRUCTIONS_MAX)
140 throw new Error(`the instructions are ${body.length} characters; the most is ${INSTRUCTIONS_MAX}`)
141 agent.instructions = body
142 } catch (err) {
143 agent.error = err instanceof Error ? err.message : String(err)
144 }
145 return agent
146}
147
148export function serializeAgent(input: AgentInput): string {
149 const search = { ...emptySearch(), ...input.search }
150 const front: Record<string, unknown> = { name: input.name.trim() }
151 if (input.description?.trim()) front['description'] = input.description.trim()
152 if (input.model?.trim()) front['model'] = input.model.trim()
153 if (input.color) front['color'] = input.color
154 if (input.web) front['web'] = true
155 const skills = [...new Set((input.skills ?? []).map((s) => s.trim()).filter(Boolean))]
156 if (skills.length) front['skills'] = skills
157 if (search.documents.length) front['documents'] = search.documents
158 if (search.forms.length) front['form'] = search.forms
159 if (search.origins.length) front['origin'] = search.origins
160 if (search.since) front['since'] = search.since
161 return `---\n${stringifyYaml(front, { lineWidth: 0 }).trimEnd()}\n---\n\n${input.instructions.trim()}\n`
162}
163
164/** The id a file gives an agent, or null for a file that is not one. */
165export function agentIdOf(path: string): string | null {
166 const name = path.slice(path.lastIndexOf('/') + 1)
167 return name.endsWith('.md') && !name.startsWith('.') ? name.slice(0, -'.md'.length) : null
168}
169
170function frontmatter(source: string): Record<string, unknown> {
171 let data: unknown
172 try {
173 data = source.trim() === '' ? {} : parseYaml(source)
174 } catch (err) {
175 throw new Error(
176 `the frontmatter is not valid YAML: ${(err instanceof Error ? err.message : String(err)).split('\n')[0]}`,
177 )
178 }
179 if (typeof data !== 'object' || data === null || Array.isArray(data))
180 throw new Error('the frontmatter has to be key: value lines')
181 return data as Record<string, unknown>
182}
183
184function textField(value: unknown, field: string, max: number, required: boolean): string {
185 if (value === undefined || value === null || value === '') {
186 if (required) throw new Error(`${field} is missing`)
187 return ''
188 }
189 if (typeof value !== 'string' && typeof value !== 'number') throw new Error(`${field} has to be text`)
190 const text = String(value).trim()
191 if (required && text === '') throw new Error(`${field} is missing`)
192 if (text.length > max) throw new Error(`${field} is ${text.length} characters; the most is ${max}`)
193 return text
194}
195
196function names(value: unknown, field: string): string[] {
197 if (value === undefined || value === null || value === '') return []
198 const list: unknown = typeof value === 'string' ? [value] : value
199 if (!Array.isArray(list) || !list.every((item) => typeof item === 'string' && item.trim() !== '')) {
200 throw new Error(`${field} has to be a list of names, like [notes]`)
201 }
202 return [...new Set(list.map((item: string) => item.trim()))]
203}
204
205function origins(value: unknown): string[] {
206 const list = names(value, 'origin')
207 const wrong = list.filter((o) => !ORIGINS.includes(o))
208 if (wrong.length > 0) throw new Error(`origin ${wrong.join(', ')} is not one; the origins are ${ORIGINS.join(', ')}`)
209 return list
210}
211
212function since(value: unknown): string | null {
213 if (value === undefined || value === null || value === '') return null
214 if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}/.test(value) || Number.isNaN(Date.parse(value))) {
215 throw new Error('since has to be a date, like 2026-01-31')
216 }
217 return value
218}
219
220function flag(value: unknown, field: string): boolean {
221 if (value === undefined || value === null || value === '') return false
222 if (typeof value !== 'boolean') throw new Error(`${field} has to be true or false`)
223 return value
224}
225
226function color(value: unknown): string | null {
227 if (value === undefined || value === null || value === '') return null
228 if (typeof value !== 'string' || !COLOR.test(value))
229 throw new Error('color has to be #rrggbb in quotes, like "#1E3A5F"')
230 return value
231}