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 { 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 | */ |
| 12 | export 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 | |
| 23 | export 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. */ |
| 42 | export 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. */ |
| 55 | export const CHUNKS = 3 |
| 56 | export const ORIGINS = ['sec', 'upload', 'web'] |
| 57 | |
| 58 | export 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 | */ |
| 64 | export 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. */ |
| 79 | export 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 | |
| 90 | export const INSTRUCTIONS_MAX = 10_000 |
| 91 | const NAME_MAX = 80 |
| 92 | const DESCRIPTION_MAX = 500 |
| 93 | const MODEL_MAX = 200 |
| 94 | const COLOR = /^#[0-9a-fA-F]{6}$/ |
| 95 | const FIELDS = ['name', 'description', 'model', 'color', 'web', 'skills', 'documents', 'form', 'origin', 'since'] |
| 96 | /** The frontmatter: a --- line, anything, and the next line that is only ---. */ |
| 97 | const FRONTMATTER = /^---\n([\s\S]*?)^---[ \t]*$/m |
| 98 | |
| 99 | export function emptySearch(): AgentSearch { |
| 100 | return { documents: [], forms: [], origins: [], since: null } |
| 101 | } |
| 102 | |
| 103 | export 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 | |
| 148 | export 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. */ |
| 165 | export 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 | |
| 170 | function 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 | |
| 184 | function 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 | |
| 196 | function 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 | |
| 205 | function 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 | |
| 212 | function 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 | |
| 220 | function 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 | |
| 226 | function 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 | } |