Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions src/engine/agent-loop.ts
Original file line number Diff line number Diff line change
@@ -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<AgentEvent> {
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<string, unknown>,
tools: tools as unknown as Record<string, unknown>,
})
}

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<string, unknown>,
})
}

// 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<string, unknown>
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<string, unknown>,
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<string, unknown>,
result: result as Record<string, unknown>,
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<string> {
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<string, unknown>,
})
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<number>()
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)
}
}
Loading
Loading