Files
sausage/extensions/core/agents.ts
T

60 lines
3.1 KiB
TypeScript

/** YAML roster entries, skill metadata, model references, and inherited tools. */
import { parse } from 'yaml'
export const CONFIG = '/etc/sausage/config.yaml'
export const thinkingLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
export type Thinking = typeof thinkingLevels[number]
export interface Definition {
name: string
description: string
path: string
instructions: string
model: string
thinking?: Thinking
tools?: string[]
addTools?: string[]
denyTools?: string[]
isolate?: 'none' | 'worktree'
resident?: boolean
fast?: boolean
summaries?: boolean
icon?: string
tint?: string
}
export function parseFrontmatter(text: string) {
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/.exec(text)
if (!match) throw new Error('Expected frontmatter fields between --- lines')
return {frontmatter:parse(match[1]) as Record<string,unknown>, body:match[2].trim()}
}
export function parseRoster(text: string, path: string) {
const value = parse(text)
if (!value || !Array.isArray(value.roster)) throw new Error(`${path}: roster must be a list`)
const names = new Set<string>()
const definitions = value.roster.map((entry: any): Definition => {
if (!entry || typeof entry.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error(`${path}: invalid roster name`)
if (names.has(entry.name)) throw new Error(`${path}: duplicate agent ${entry.name}`)
names.add(entry.name)
for (const key of ['description', 'prompt', 'model']) if (typeof entry[key] !== 'string' || !entry[key].trim()) throw new Error(`${path}: ${entry.name}.${key} is required`)
if (entry.thinking !== undefined && !thinkingLevels.includes(entry.thinking)) throw new Error(`${path}: invalid thinking for ${entry.name}`)
for (const key of ['tools', 'addTools', 'denyTools']) if (entry[key] !== undefined && (!Array.isArray(entry[key]) || !entry[key].every((tool: any)=>typeof tool === 'string'))) throw new Error(`${path}: ${entry.name}.${key} must be a list of tool names`)
const {prompt, ...settings} = entry
const definition = {...settings, path, instructions:prompt}
definitionRuns(definition)
return definition
})
return {...value, definitions}
}
export function parseConfig(text: string) {
const value = parseRoster(text, CONFIG)
if (typeof value.prompt !== 'string') throw new Error(`${CONFIG}: prompt must be a string`)
if (!value.definitions.some((entry: Definition)=>entry.name === value.standing)) throw new Error(`${CONFIG}: standing must name a roster member`)
return value
}
export function definitionRuns(definition: Definition) {
if (!/^[^/\s]+\/\S+$/.test(definition.model)) throw new Error(`${definition.path}: invalid model for ${definition.name}`)
const slash = definition.model.indexOf('/')
return {model:{provider:definition.model.slice(0,slash),modelId:definition.model.slice(slash+1)},thinking:definition.thinking}
}
export function toolsFor(definition: Definition, parent: readonly string[]): string[] {
return [...new Set([...(definition.tools ?? parent), ...(definition.addTools ?? [])])].filter(tool => !definition.denyTools?.includes(tool))
}