diff --git a/src/engine/agent-loop.ts b/src/engine/agent-loop.ts new file mode 100644 index 0000000..a75bacc --- /dev/null +++ b/src/engine/agent-loop.ts @@ -0,0 +1,308 @@ +import type { Message, ToolCall, AgentEvent, ToolContext } from './types.js' +import type { Tool, ToolRegistry } from './tool.js' +import type { ModelAdapter } from './provider.js' +import type { PermissionSystem } from './permission.js' +import type { AgentInfo } from './agent.js' +import type { SkillService } from './skill.js' +import type { PluginEngine } from './plugin.js' + +export interface AgentOptions { + model: ModelAdapter + registry: ToolRegistry + permissions: PermissionSystem + agent: AgentInfo + sessionId: string + skills?: SkillService + plugin?: PluginEngine + maxIterations?: number +} + +export class AgentAbortedError extends Error { + constructor() { + super('Agent execution aborted') + this.name = 'AgentAbortedError' + } +} + +const SYSTEM_PROMPT_BASE = 'You are an AI coding agent. Help the user accomplish software engineering tasks.' + +export class Agent { + private model: ModelAdapter + private registry: ToolRegistry + private permissions: PermissionSystem + private agent: AgentInfo + private sessionId: string + private systemPrompt: string + private maxIterations: number + private skills?: SkillService + private plugin?: PluginEngine + + constructor(opts: AgentOptions) { + this.model = opts.model + this.registry = opts.registry + this.permissions = opts.permissions + this.agent = opts.agent + this.sessionId = opts.sessionId + this.systemPrompt = opts.agent.system ?? SYSTEM_PROMPT_BASE + this.maxIterations = opts.agent.steps ?? opts.maxIterations ?? 10 + this.skills = opts.skills + this.plugin = opts.plugin + } + + async *run(input: string, options?: { signal?: AbortSignal }): AsyncIterable { + if (!input || input.trim() === '') return + + const signal = options?.signal + + const effectiveSystem = await this.buildSystemPrompt(input) + const messages: Message[] = [ + { role: 'system', content: effectiveSystem }, + { role: 'user', content: input }, + ] + + const tools = this.registry.list() + + for (let step = 0; step < this.maxIterations; step++) { + if (signal?.aborted) throw new AgentAbortedError() + + this.truncateMessages(messages) + + // Hook: chat:before + if (this.plugin) { + await this.plugin.trigger('chat:before', { + messages: messages as unknown as Record, + tools: tools as unknown as Record, + }) + } + + let response: Message + try { + const result = await this.model.chat({ messages, tools, signal }) + response = result.message + } catch (err) { + yield { type: 'error', error: err instanceof Error ? err : new Error(String(err)) } + if (this.plugin) { + await this.plugin.trigger('chat:error', { + error: String(err), + sessionId: this.sessionId, + }) + } + break + } + + messages.push(response) + + // Hook: chat:after + if (this.plugin) { + await this.plugin.trigger('chat:after', { + response: response as unknown as Record, + }) + } + + // Emit text + if (response.content) { + yield { type: 'text_delta', content: response.content } + } + + // No tool calls — done + if (!response.tool_calls || response.tool_calls.length === 0) { + yield { type: 'done' } + if (this.plugin) { + await this.plugin.trigger('agent:done', { + sessionId: this.sessionId, + steps: step + 1, + }) + } + return + } + + // Process tool calls + const toolCalls = response.tool_calls + const validationErrors = this.validateToolCalls(toolCalls) + + if (validationErrors.length > 0) { + messages.push({ + role: 'user', + content: `Tool call validation failed: ${validationErrors.map((e) => e.reason).join('; ')}. Please fix and retry.`, + }) + continue + } + + for (const tc of toolCalls) { + if (signal?.aborted) throw new AgentAbortedError() + + let args: Record + try { + args = JSON.parse(tc.function.arguments) + } catch { + messages.push({ + role: 'tool', + tool_call_id: tc.id, + content: `Error: invalid JSON in arguments: ${tc.function.arguments}`, + }) + continue + } + + // Hook: tool:before + if (this.plugin) { + await this.plugin.trigger('tool:before', { + tool: tc.function.name, + callId: tc.id, + args: args as Record, + sessionId: this.sessionId, + }) + } + + yield { type: 'tool_call', tool: tc.function.name, args } + + const toolContext: ToolContext = { + agent: this.agent.name, + permissions: Object.fromEntries( + this.agent.permissions.map((r) => [r.action, r.effect]), + ), + signal, + sessionId: this.sessionId, + workspaceRoot: process.cwd(), + } + + const permCheck = this.permissions.check(tc.function.name, args, toolContext) + if (!permCheck.allowed) { + const result = { error: 'permission_denied', reason: permCheck.reason } + yield { type: 'tool_result', tool: tc.function.name, result } + messages.push({ + role: 'tool', + tool_call_id: tc.id, + content: JSON.stringify(result), + }) + continue + } + + const tool = this.registry.get(tc.function.name) + if (!tool) { + const result = { error: 'tool_not_found', name: tc.function.name } + yield { type: 'tool_result', tool: tc.function.name, result } + messages.push({ + role: 'tool', + tool_call_id: tc.id, + content: JSON.stringify(result), + }) + continue + } + + try { + const result = await tool.execute(args, toolContext) + yield { type: 'tool_result', tool: tc.function.name, result } + messages.push({ + role: 'tool', + tool_call_id: tc.id, + content: JSON.stringify(result), + }) + + if (this.plugin) { + await this.plugin.trigger('tool:after', { + tool: tc.function.name, + callId: tc.id, + args: args as Record, + result: result as Record, + sessionId: this.sessionId, + }) + } + } catch (err) { + const errorResult = { error: err instanceof Error ? err.message : String(err) } + yield { type: 'tool_result', tool: tc.function.name, result: errorResult } + messages.push({ + role: 'tool', + tool_call_id: tc.id, + content: JSON.stringify(errorResult), + }) + + if (this.plugin) { + await this.plugin.trigger('tool:error', { + tool: tc.function.name, + callId: tc.id, + error: String(err), + sessionId: this.sessionId, + }) + } + } + } + } + + yield { type: 'done' } + } + + private async buildSystemPrompt(input: string): Promise { + const parts: string[] = [this.systemPrompt] + + if (this.skills) { + const list = this.skills.list() + if (list.length > 0) { + const { resolveSkillPrompt } = await import('./skill.js') + parts.push(resolveSkillPrompt(list)) + } + } + + if (this.plugin) { + const result = await this.plugin.trigger('system:prompt', { + input, + agentId: this.agent.id, + sessionId: this.sessionId, + prompt: parts as unknown as Record, + }) + if (Array.isArray((result as { prompt?: string[] }).prompt)) { + return (result as { prompt: string[] }).prompt.join('\n\n') + } + } + + return parts.join('\n\n') + } + + private validateToolCalls(calls: ToolCall[]): { index: number; reason: string }[] { + const errors: { index: number; reason: string }[] = [] + for (let i = 0; i < calls.length; i++) { + const tc = calls[i] + if (!this.registry.has(tc.function.name)) { + errors.push({ index: i, reason: `Unknown tool: "${tc.function.name}"` }) + } + try { + JSON.parse(tc.function.arguments) + } catch { + errors.push({ index: i, reason: `Invalid JSON arguments for "${tc.function.name}"` }) + } + } + return errors + } + + private truncateMessages(messages: Message[]): void { + const maxTokens = 128_000 + const total = messages.reduce((sum, m) => sum + m.content.length, 0) + + if (total <= maxTokens * 0.8 * 3.5) return + + const seen = new Set() + const kept: Message[] = [messages[0]] + + for (let i = 1; i < messages.length - 2; i++) { + if (messages[i].role === 'tool') { + kept.push(messages[i]) + seen.add(i) + } + } + + for (const msg of messages.slice(-3)) { + const idx = messages.indexOf(msg) + if (!seen.has(idx)) { + kept.push(msg) + } + } + + messages.length = 0 + messages.push(...kept) + + const newTotal = messages.reduce((sum, m) => sum + m.content.length, 0) + if (newTotal <= maxTokens * 0.8 * 3.5) return + + if (messages.length <= 2) return + messages.splice(1, 2) + } +} diff --git a/src/engine/agent.ts b/src/engine/agent.ts new file mode 100644 index 0000000..9cf084f --- /dev/null +++ b/src/engine/agent.ts @@ -0,0 +1,160 @@ +import type { AgentPermissions } from './types.js' +import type { PermissionRuleset } from './permission.js' +import type { ModelRef } from './provider.js' + +export interface AgentInfo { + id: string + name: string + description?: string + mode: 'primary' | 'subagent' | 'all' + hidden?: boolean + system?: string + model?: ModelRef + permissions: PermissionRuleset + temperature?: number + topP?: number + steps?: number + color?: string +} + +export function agentInfoFromConfig( + id: string, + config: { description?: string; model?: string; temperature?: number; permission?: AgentPermissions; color?: string; systemPrompt?: string }, +): AgentInfo { + return { + id, + name: id, + description: config.description, + mode: 'all', + system: config.systemPrompt, + permissions: config.permission + ? Object.entries(config.permission).map(([action, effect]) => ({ + action, + resource: '*', + effect, + })) + : [], + temperature: config.temperature, + color: config.color, + } +} + +export const BUILTIN_AGENTS: Record = { + build: { + id: 'build', + name: 'build', + description: 'The default agent. Executes tools based on configured permissions.', + mode: 'primary', + permissions: [{ action: '*', resource: '*', effect: 'allow' }], + }, + plan: { + id: 'plan', + name: 'plan', + description: 'Plan mode. Disallows all edit tools.', + mode: 'primary', + permissions: [ + { action: '*', resource: '*', effect: 'allow' }, + { action: 'edit', resource: '*', effect: 'deny' }, + { action: 'write', resource: '*', effect: 'deny' }, + { action: 'bash', resource: '*', effect: 'deny' }, + ], + }, + explore: { + id: 'explore', + name: 'explore', + description: 'Fast agent specialized for exploring codebases.', + mode: 'subagent', + system: `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. + +Your strengths: +- Rapidly finding files using glob patterns +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents + +Guidelines: +- Use Glob for broad file pattern matching +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path you need to read +- Return file paths as absolute paths in your final response +- Do not create any files, or run bash commands that modify the user's system state in any way`, + permissions: [ + { action: '*', resource: '*', effect: 'deny' }, + { action: 'glob', resource: '*', effect: 'allow' }, + { action: 'grep', resource: '*', effect: 'allow' }, + { action: 'read', resource: '*', effect: 'allow' }, + ], + }, + general: { + id: 'general', + name: 'general', + description: 'General-purpose agent for researching complex questions and executing multi-step tasks.', + mode: 'subagent', + permissions: [{ action: '*', resource: '*', effect: 'allow' }], + }, + title: { + id: 'title', + name: 'title', + hidden: true, + mode: 'primary', + system: `You are a title generator. Output ONLY a brief title (<=50 chars). No explanations.`, + permissions: [{ action: '*', resource: '*', effect: 'deny' }], + }, + summary: { + id: 'summary', + name: 'summary', + hidden: true, + mode: 'primary', + system: `Summarize what was done in this conversation in 2-3 sentences.`, + permissions: [{ action: '*', resource: '*', effect: 'deny' }], + }, + compaction: { + id: 'compaction', + name: 'compaction', + hidden: true, + mode: 'primary', + system: `You are a context summarization assistant. Summarize the conversation history, preserving key facts, file paths, and decisions.`, + permissions: [{ action: '*', resource: '*', effect: 'deny' }], + }, +} + +export class AgentService { + private agents = new Map() + + constructor(builtins: Record = BUILTIN_AGENTS) { + for (const [id, info] of Object.entries(builtins)) { + this.agents.set(id, { ...info }) + } + } + + register(info: AgentInfo): void { + this.agents.set(info.id, { ...info }) + } + + get(id: string): AgentInfo | undefined { + return this.agents.get(id) + } + + require(id: string): AgentInfo { + const info = this.agents.get(id) + if (!info) throw new Error(`Agent not found: "${id}"`) + return info + } + + list(): AgentInfo[] { + return Array.from(this.agents.values()) + } + + listVisible(): AgentInfo[] { + return this.list().filter((a) => !a.hidden) + } + + default(): AgentInfo { + const visible = this.agents.get('build') ?? this.list().find((a) => a.mode === 'primary' && !a.hidden) + if (!visible) throw new Error('No primary visible agent found') + return visible + } + + remove(id: string): void { + this.agents.delete(id) + } +} diff --git a/src/engine/index.ts b/src/engine/index.ts new file mode 100644 index 0000000..3b4d3d6 --- /dev/null +++ b/src/engine/index.ts @@ -0,0 +1,9 @@ +export * from './types.js' +export * from './tool.js' +export * from './permission.js' +export * from './provider.js' +export * from './agent.js' +export * from './session.js' +export * from './plugin.js' +export * from './skill.js' +export * from './agent-loop.js' diff --git a/src/engine/permission.ts b/src/engine/permission.ts new file mode 100644 index 0000000..5cc0961 --- /dev/null +++ b/src/engine/permission.ts @@ -0,0 +1,104 @@ +import type { PermissionLevel, AgentPermissions } from './types.js' + +export interface PermissionRule { + action: string + resource: string + effect: PermissionLevel +} + +export type PermissionRuleset = PermissionRule[] + +const LEVEL_ORDER: Record = { + allow: 0, + ask: 1, + restricted: 2, + deny: 3, +} + +function stricter(a: PermissionLevel, b: PermissionLevel): PermissionLevel { + return LEVEL_ORDER[a] >= LEVEL_ORDER[b] ? a : b +} + +export function mergeRulesets(...rulesets: PermissionRuleset[]): PermissionRuleset { + const merged = new Map() + for (const ruleset of rulesets) { + for (const rule of ruleset) { + const key = `${rule.action}:${rule.resource}` + const existing = merged.get(key) + if (!existing || LEVEL_ORDER[rule.effect] > LEVEL_ORDER[existing.effect]) { + merged.set(key, rule) + } + } + } + return Array.from(merged.values()) +} + +export function mergePermissions( + parent: AgentPermissions, + child?: AgentPermissions, +): AgentPermissions { + if (!child) return { ...parent } + const merged: AgentPermissions = { ...parent } + for (const [key, value] of Object.entries(child)) { + if (key in merged) { + merged[key] = stricter(merged[key], value) + } else { + merged[key] = value + } + } + return merged +} + +function wildcardMatch(pattern: string, value: string): boolean { + if (pattern === '*') return true + const regex = new RegExp( + '^' + + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + + '$', + ) + return regex.test(value) +} + +export interface PermissionCheck { + allowed: boolean + level: PermissionLevel + reason?: string +} + +export class PermissionSystem { + constructor(private rules: PermissionRuleset = []) {} + + setRules(rules: PermissionRuleset): void { + this.rules = rules + } + + check( + tool: string, + args: Record, + context: { permissions: AgentPermissions }, + ): PermissionCheck { + const level = context.permissions[tool] ?? context.permissions['*'] ?? 'ask' + + switch (level) { + case 'allow': + return { allowed: true, level: 'allow' } + case 'deny': + return { allowed: false, level: 'deny', reason: `Tool "${tool}" is denied` } + case 'restricted': + return { allowed: false, level: 'restricted', reason: `Tool "${tool}" is restricted` } + case 'ask': + default: + return { allowed: false, level: 'ask', reason: `Tool "${tool}" requires confirmation` } + } + } + + checkRule(action: string, resource: string): { effect: PermissionLevel; matchedRule?: PermissionRule } { + for (let i = this.rules.length - 1; i >= 0; i--) { + const rule = this.rules[i] + if (wildcardMatch(rule.action, action) && wildcardMatch(rule.resource, resource)) { + return { effect: rule.effect, matchedRule: rule } + } + } + return { effect: 'ask' } + } +} diff --git a/src/engine/plugin.ts b/src/engine/plugin.ts new file mode 100644 index 0000000..9766dde --- /dev/null +++ b/src/engine/plugin.ts @@ -0,0 +1,109 @@ +import type { Tool } from './tool.js' +import type { ProviderFactory } from './provider.js' +import type { AgentInfo } from './agent.js' + +export interface PluginAPI { + registerTool(tool: Tool): void + unregisterTool(name: string): void + registerProvider(id: string, factory: ProviderFactory): void + registerAgent(agent: AgentInfo): void + registerService(name: string, service: T): void + on(event: string, handler: HookHandler): void + trigger(event: string, data: Record): Promise> + getService(name: string): T | undefined +} + +export type HookHandler = (data: Record) => void | Promise + +export interface Plugin { + id: string + name: string + description: string + setup(api: PluginAPI): void | Promise +} + +type HookEntry = { + pluginId: string + handler: HookHandler +} + +export class PluginEngine { + private plugins = new Map() + private hooks = new Map() + private services = new Map() + private isSetup = new Set() + + constructor() {} + + registerService(name: string, service: T): void { + this.services.set(name, service) + } + + async load(plugin: Plugin): Promise { + if (this.plugins.has(plugin.id)) { + throw new Error(`Plugin "${plugin.id}" is already loaded`) + } + this.plugins.set(plugin.id, plugin) + + const api: PluginAPI = { + registerTool: (tool) => { + const registry = this.services.get('toolRegistry') as { register(tool: Tool): void } | undefined + registry?.register(tool) + }, + unregisterTool: (name) => { + const registry = this.services.get('toolRegistry') as { remove(name: string): void } | undefined + registry?.remove(name) + }, + registerProvider: (id, factory) => { + const registry = this.services.get('providerRegistry') + if (registry && typeof (registry as Record).register === 'function') { + ;(registry as { register(id: string, factory: ProviderFactory): void }).register(id, factory) + } + }, + registerAgent: (agent) => { + const service = this.services.get('agentService') as { register(agent: AgentInfo): void } | undefined + service?.register(agent) + }, + on: (event, handler) => { + const entries = this.hooks.get(event) ?? [] + entries.push({ pluginId: plugin.id, handler }) + this.hooks.set(event, entries) + }, + trigger: async (event, data) => { + return this.trigger(event, data) + }, + getService: (name: string): T | undefined => { + return this.services.get(name) as T | undefined + }, + registerService: (name, service) => { + this.services.set(name, service) + }, + } + + await plugin.setup(api) + this.isSetup.add(plugin.id) + } + + async unload(id: string): Promise { + this.plugins.delete(id) + this.isSetup.delete(id) + for (const [event, entries] of this.hooks) { + this.hooks.set( + event, + entries.filter((e) => e.pluginId !== id), + ) + } + } + + async trigger(event: string, data: Record): Promise> { + const entries = this.hooks.get(event) ?? [] + for (const entry of entries) { + await entry.handler(data) + } + return data + } + + listPlugins(): string[] { + return Array.from(this.plugins.keys()) + } +} diff --git a/src/engine/provider.ts b/src/engine/provider.ts new file mode 100644 index 0000000..7e561da --- /dev/null +++ b/src/engine/provider.ts @@ -0,0 +1,57 @@ +import type { Message, ToolDef, ChatRequest, ChatResponse, ChatChunk } from './types.js' + +export interface ModelAdapter { + chat(req: ChatRequest): Promise + stream?(req: ChatRequest): AsyncIterable +} + +export interface ProviderFactory { + create(options: Record): ModelAdapter +} + +export interface ProviderInfo { + id: string + name: string + models: ModelInfo[] + defaultModel: string +} + +export interface ModelInfo { + id: string + name: string + maxTokens: number + supportsVision?: boolean +} + +export interface ModelRef { + providerID: string + modelID: string +} + +export interface TokenUsage { + input: number + output: number + total: number + reasoning?: number + cache?: { read: number; write: number } +} + +export class ProviderRegistry { + private providers = new Map() + + register(id: string, factory: ProviderFactory): void { + this.providers.set(id, factory) + } + + get(id: string): ProviderFactory | undefined { + return this.providers.get(id) + } + + list(): string[] { + return Array.from(this.providers.keys()) + } + + remove(id: string): void { + this.providers.delete(id) + } +} diff --git a/src/engine/session.ts b/src/engine/session.ts new file mode 100644 index 0000000..cf26588 --- /dev/null +++ b/src/engine/session.ts @@ -0,0 +1,87 @@ +import type { Message } from './types.js' +import type { ModelRef } from './provider.js' + +export interface Session { + id: string + agent: string + title: string + model: ModelRef + messages: Message[] + metadata: Record + cost: number + tokens: TokenCount + time: SessionTime +} + +export interface TokenCount { + input: number + output: number + reasoning: number +} + +export interface SessionTime { + created: number + updated: number +} + +export function createSession( + id: string, + agent: string, + model: ModelRef, + title?: string, +): Session { + const now = Date.now() + return { + id, + agent, + title: title ?? `New session - ${new Date(now).toISOString()}`, + model, + messages: [], + metadata: {}, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0 }, + time: { created: now, updated: now }, + } +} + +export function addMessage(session: Session, msg: Message): void { + session.messages.push(msg) + session.time.updated = Date.now() +} + +export function updateMessage(session: Session, msg: Message): void { + const idx = session.messages.findIndex((m) => { + // match by role + tool_call_id for tool messages, just role for others + if (msg.role === 'tool' && m.role === 'tool') { + return m.tool_call_id === msg.tool_call_id + } + return false + }) + if (idx >= 0) { + session.messages[idx] = msg + } else { + session.messages.push(msg) + } + session.time.updated = Date.now() +} + +export function updateTokens( + session: Session, + input: number, + output: number, + reasoning = 0, + cost = 0, +): void { + session.tokens.input += input + session.tokens.output += output + session.tokens.reasoning += reasoning + session.cost += cost + session.time.updated = Date.now() +} + +export const SessionManager = { + create: createSession, + addMessage, + updateMessage, + updateTokens, +} diff --git a/src/engine/skill.ts b/src/engine/skill.ts new file mode 100644 index 0000000..dc3e8a6 --- /dev/null +++ b/src/engine/skill.ts @@ -0,0 +1,92 @@ +export interface SkillSource { + type: 'directory' | 'url' | 'embedded' + path?: string + url?: string + skill?: SkillInfo +} + +export interface SkillInfo { + name: string + description?: string + location: string + content: string + triggers?: string[] +} + +export function resolveSkillPrompt( + skills: SkillInfo[], + verbose = true, +): string { + const described = skills.filter((s) => s.description !== undefined) + if (described.length === 0) { + return 'No skills are currently available.' + } + if (verbose) { + return [ + 'Skills provide specialized instructions and workflows for specific tasks.', + 'Use the skill tool to load a skill when a task matches its description.', + '', + ...described.flatMap((skill) => [ + ' ', + ` ${skill.name}`, + ` ${skill.description}`, + ` ${skill.location}`, + ' ', + ]), + '', + ].join('\n') + } + return [ + '## Available Skills', + ...described.map((skill) => `- **${skill.name}**: ${skill.description}`), + ].join('\n') +} + +export function buildSkillContent( + skill: SkillInfo, + files: string[], +): string { + return [ + ``, + `# Skill: ${skill.name}`, + '', + skill.content.trim(), + '', + ...(files.length > 0 + ? ['', ...files.map((f) => `${f}`), ''] + : []), + '', + ].join('\n') +} + +export class SkillService { + private skills = new Map() + private sources: SkillSource[] = [] + + addSource(source: SkillSource): void { + this.sources.push(source) + if (source.type === 'embedded' && source.skill) { + this.skills.set(source.skill.name, source.skill) + } + } + + register(skill: SkillInfo): void { + this.skills.set(skill.name, skill) + } + + get(name: string): SkillInfo | undefined { + return this.skills.get(name) + } + + list(): SkillInfo[] { + return Array.from(this.skills.values()).sort((a, b) => a.name.localeCompare(b.name)) + } + + remove(name: string): void { + this.skills.delete(name) + } + + clear(): void { + this.skills.clear() + } +} diff --git a/src/engine/tool.ts b/src/engine/tool.ts new file mode 100644 index 0000000..188ee25 --- /dev/null +++ b/src/engine/tool.ts @@ -0,0 +1,52 @@ +import type { ToolContext, ToolDef } from './types.js' + +export interface Tool { + name: string + description: string + inputSchema: Record + execute(args: Record, context: ToolContext): Promise +} + +export class ToolRegistry { + private tools = new Map() + + register(tool: Tool): void { + this.tools.set(tool.name, tool) + } + + get(name: string): Tool | undefined { + return this.tools.get(name) + } + + list(): ToolDef[] { + return Array.from(this.tools.values()).map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })) + } + + has(name: string): boolean { + return this.tools.has(name) + } + + remove(name: string): void { + this.tools.delete(name) + } + + unregister(name: string): void { + this.remove(name) + } + + removeByPrefix(prefix: string): void { + for (const [name] of this.tools) { + if (name.startsWith(prefix)) { + this.tools.delete(name) + } + } + } + + unregisterByPrefix(prefix: string): void { + this.removeByPrefix(prefix) + } +} diff --git a/src/engine/types.ts b/src/engine/types.ts new file mode 100644 index 0000000..0136a7c --- /dev/null +++ b/src/engine/types.ts @@ -0,0 +1,114 @@ +export type Role = 'system' | 'user' | 'assistant' | 'tool' + +export interface Message { + role: Role + content: string + tool_calls?: ToolCall[] + tool_call_id?: string +} + +export interface ToolCall { + id: string + type: 'function' + function: { + name: string + arguments: string + } +} + +export interface ToolResult { + tool_call_id: string + content: string + isError?: boolean +} + +export interface ToolDef { + name: string + description: string + inputSchema: Record +} + +export interface ChatRequest { + messages: Message[] + tools: ToolDef[] + signal?: AbortSignal +} + +export interface ChatResponse { + message: Message + usage?: { + promptTokens: number + completionTokens: number + totalTokens: number + } +} + +export interface ChatChunk { + type: 'text' | 'tool_call' + content: string + index: number +} + +export interface ToolContext { + agent: string + permissions: Record + signal?: AbortSignal + sessionId: string + workspaceRoot?: string +} + +export type AgentEvent = + | { type: 'text_delta'; content: string } + | { type: 'tool_call'; tool: string; args: unknown } + | { type: 'tool_result'; tool: string; result: unknown } + | { type: 'error'; error: Error } + | { type: 'done' } + +export type PermissionLevel = 'allow' | 'deny' | 'ask' | 'restricted' + +export type AgentPermissions = Record + +export interface AgentConfig { + description?: string + model?: string + temperature?: number + permission?: AgentPermissions + color?: string + systemPrompt?: string +} + +export interface Skill { + name: string + trigger: string[] + description?: string + path?: string + prompt?: string +} + +export interface SessionConfig { + id: string + agent: string + messages: Message[] + createdAt: string + updatedAt: string +} + +export interface DaisyConfig { + default_agent?: string + model?: string + agent?: Record + skill?: Record + mcp?: Record +} + +export type MCPProcessState = 'created' | 'starting' | 'ready' | 'healthy' | 'error' | 'fatal' + +export interface MCPProcessConfig { + command: string + args: string[] + env?: Record + startupTimeout?: number + healthInterval?: number + maxRestarts?: number + requestTimeout?: number +} diff --git a/src/plugins/mcp.ts b/src/plugins/mcp.ts new file mode 100644 index 0000000..22a24f7 --- /dev/null +++ b/src/plugins/mcp.ts @@ -0,0 +1,21 @@ +import type { Plugin } from '../engine/plugin.js' +import type { ToolRegistry } from '../engine/tool.js' +import { MCPManager } from '../mcp/manager.js' + +export function createMCPPlugin(toolRegistry: ToolRegistry): Plugin { + const manager = new MCPManager(toolRegistry as any) + + return { + id: 'mcp', + name: 'MCP Protocol', + description: 'Model Context Protocol — external tool servers', + + async setup(api: Parameters[0]) { + api.registerService('mcpManager', manager) + + api.on('system:shutdown', async () => { + await manager.close() + }) + }, + } +} diff --git a/src/plugins/memory.ts b/src/plugins/memory.ts new file mode 100644 index 0000000..a7b276b --- /dev/null +++ b/src/plugins/memory.ts @@ -0,0 +1,16 @@ +import type { Plugin } from '../engine/plugin.js' + +export function createMemoryPlugin(): Plugin { + return { + id: 'memory', + name: 'Cross-Session Memory', + description: 'Persistent user and project memory across sessions', + + setup(api: Parameters[0]) { + api.on('system:prompt', async (data: Record) => { + const prompt = (data.prompt as unknown as string[]) || [] + prompt.push('[Memory plugin placeholder — will integrate with memory/ module]') + }) + }, + } +} diff --git a/src/plugins/orchestrator.ts b/src/plugins/orchestrator.ts new file mode 100644 index 0000000..12eab78 --- /dev/null +++ b/src/plugins/orchestrator.ts @@ -0,0 +1,55 @@ +import type { Plugin } from '../engine/plugin.js' +import type { ToolRegistry } from '../engine/tool.js' +import type { PermissionSystem } from '../engine/permission.js' +import type { ModelAdapter } from '../model-adapter.js' +import { Orchestrator } from '../orchestrator.js' + +export function createOrchestratorPlugin( + registry: ToolRegistry, + model: ModelAdapter, + permissions: PermissionSystem, +): Plugin { + const orchestrator = new Orchestrator(model, registry as any, permissions as any) + + return { + id: 'orchestrator', + name: 'Sub-Agent Orchestration', + description: 'Delegate tasks to sub-agents with depth limits and file locking', + + setup(api: Parameters[0]) { + api.registerTool({ + name: 'task', + description: 'Launch a new agent to handle complex, multistep tasks autonomously.', + inputSchema: { + type: 'object', + properties: { + description: { type: 'string', description: 'A short description of the task' }, + prompt: { type: 'string', description: 'The task for the agent to perform' }, + subagent_type: { type: 'string', description: 'The type of agent to use' }, + }, + required: ['description', 'prompt', 'subagent_type'], + }, + async execute(args: Record) { + const agentType = (args.subagent_type as string) || 'general' + const session = (orchestrator as any).createSubagent( + { name: agentType }, + '', + undefined, + ) + const result = await orchestrator.runSubagent( + session.session, + args.prompt as string, + session.permissions, + undefined, + false, + ) + return { + success: result.success, + output: result.output, + error: result.error, + } + }, + }) + }, + } +} diff --git a/src/plugins/providers/index.ts b/src/plugins/providers/index.ts new file mode 100644 index 0000000..a4fb0ac --- /dev/null +++ b/src/plugins/providers/index.ts @@ -0,0 +1,28 @@ +import type { Plugin } from '../../engine/plugin.js' + +export const BuiltinProvidersPlugin: Plugin = { + id: 'builtin-providers', + name: 'Built-in LLM Providers', + description: 'OpenAI-compatible and Anthropic providers', + + setup(api: Parameters[0]) { + api.on('system:boot', async () => { + const { OpenAICompatibleAdapter, AnthropicAdapter } = await import('../../model-adapter.js') + + api.registerProvider('deepseek', { + create: (opts: Record) => + new (OpenAICompatibleAdapter as any)({ + apiKey: opts.apiKey, + baseURL: 'https://api.deepseek.com/v1', + }), + }) + api.registerProvider('openai', { + create: (opts: Record) => + new (OpenAICompatibleAdapter as any)({ + apiKey: opts.apiKey, + baseURL: 'https://api.openai.com/v1', + }), + }) + }) + }, +} diff --git a/src/plugins/tools/index.ts b/src/plugins/tools/index.ts new file mode 100644 index 0000000..070acdb --- /dev/null +++ b/src/plugins/tools/index.ts @@ -0,0 +1,21 @@ +import type { Plugin } from '../../engine/plugin.js' +import { readTool } from '../../tools/read.js' +import { editTool, writeTool } from '../../tools/edit.js' +import { globTool } from '../../tools/glob.js' +import { grepTool } from '../../tools/grep.js' +import { bashTool } from '../../tools/bash.js' + +export const BuiltinToolsPlugin: Plugin = { + id: 'builtin-tools', + name: 'Built-in Tools', + description: 'Core file system and shell tools: read, edit, write, glob, grep, bash', + + setup(api: Parameters[0]) { + api.registerTool(readTool) + api.registerTool(editTool) + api.registerTool(writeTool) + api.registerTool(globTool) + api.registerTool(grepTool) + api.registerTool(bashTool) + }, +} diff --git a/src/types.ts b/src/types.ts index f13de66..8c80c71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,115 +1,63 @@ -export type PermissionLevel = 'allow' | 'deny' | 'ask' | 'restricted'; - -export type AgentPermissions = Record; - -export interface Message { - role: Role; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; -} - -export type Role = 'system' | 'user' | 'assistant' | 'tool'; - -export interface ToolDef { - name: string; - description: string; - inputSchema: Record; -} - -export interface ToolCall { - id: string; - type: 'function'; - function: { - name: string; - arguments: string; - }; -} - -export interface ToolResult { - tool_call_id: string; - content: string; - isError?: boolean; -} - -export interface ChatRequest { - messages: Message[]; - tools: ToolDef[]; - signal?: AbortSignal; -} - -export interface ChatResponse { - message: Message; - usage?: { - promptTokens: number; - completionTokens: number; - totalTokens: number; - }; -} - -export interface ChatChunk { - type: 'text' | 'tool_call'; - content: string; - index: number; -} - -export interface ToolContext { - agent: string; - permissions: AgentPermissions; - signal?: AbortSignal; - sessionId: string; - workspaceRoot?: string; -} - -export type AgentEvent = - | { type: 'text_delta'; content: string } - | { type: 'tool_call'; tool: string; args: unknown } - | { type: 'tool_result'; tool: string; result: unknown } - | { type: 'error'; error: Error } - | { type: 'done' }; - -export type MCPProcessState = 'created' | 'starting' | 'ready' | 'healthy' | 'error' | 'fatal'; - -export interface MCPProcessConfig { - command: string; - args: string[]; - env?: Record; - startupTimeout?: number; - healthInterval?: number; - maxRestarts?: number; - requestTimeout?: number; -} - -export interface Skill { - name: string; - trigger: string[]; - description?: string; - path?: string; - /** Populated after loading: the SKILL.md body (prompt to inject) */ - prompt?: string; -} - -export interface SessionConfig { - id: string; - agent: string; - messages: Message[]; - createdAt: string; - updatedAt: string; -} - -export interface AgentConfig { - description?: string; - model?: string; - temperature?: number; - permission?: AgentPermissions; - color?: string; - systemPrompt?: string; -} - -export interface DaisyConfig { - default_agent?: string; - model?: string; - agent?: Record; - skill?: Record; - mcp?: Record; -} +export { + PermissionLevel, + AgentPermissions, + Message, + Role, + ToolDef, + ToolCall, + ToolResult, + ChatRequest, + ChatResponse, + ChatChunk, + ToolContext, + AgentEvent, + MCPProcessState, + MCPProcessConfig, + Skill, + SessionConfig, + AgentConfig, + DaisyConfig, +} from './engine/types.js' + +export { Tool, ToolRegistry } from './engine/tool.js' +export { + PermissionRule, + PermissionRuleset, + mergeRulesets, + mergePermissions, + PermissionCheck, + PermissionSystem, +} from './engine/permission.js' +export { + ModelAdapter, + ProviderFactory, + ProviderInfo, + ModelInfo, + ModelRef, + TokenUsage, + ProviderRegistry, +} from './engine/provider.js' +export { + AgentInfo, + agentInfoFromConfig, + BUILTIN_AGENTS, + AgentService, +} from './engine/agent.js' +export { + Session, + TokenCount, + SessionTime, + createSession, + addMessage as sessionAddMessage, + updateMessage as sessionUpdateMessage, + updateTokens, + SessionManager, +} from './engine/session.js' +export { PluginAPI, Plugin, HookHandler, PluginEngine } from './engine/plugin.js' +export { + SkillSource, + SkillInfo, + resolveSkillPrompt, + buildSkillContent, + SkillService, +} from './engine/skill.js'