runtime.test.ts 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052
  1. import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { describe, expect, it, vi } from 'vitest'
  6. import {
  7. AgentRuntime,
  8. AgentToolRegistry,
  9. DelegateTaskTool,
  10. DEFAULT_AGENT_MAX_STEPS,
  11. DEFAULT_AGENT_MODEL_MAX_RETRIES,
  12. buildSystemPrompt,
  13. } from '../../packages/ekko-agent/src/index'
  14. import type {
  15. AgentTool,
  16. AgentToolProvider,
  17. ModelEvent,
  18. ModelClient,
  19. ModelRequest,
  20. ModelResponse,
  21. } from '../../packages/ekko-agent/src/index'
  22. function modelClient(responder: (request: ModelRequest, call: number) => ModelResponse): ModelClient {
  23. let call = 0
  24. return {
  25. provider: 'test',
  26. requestStyle: 'custom-runtime',
  27. capabilities: {
  28. streaming: false,
  29. tools: true,
  30. vision: false,
  31. jsonMode: false,
  32. systemPrompt: true,
  33. },
  34. create: vi.fn(async (request: ModelRequest) => responder(request, ++call)),
  35. stream: vi.fn(),
  36. }
  37. }
  38. function streamingModelClient(events: ModelEvent[]): ModelClient {
  39. return {
  40. provider: 'test',
  41. requestStyle: 'custom-runtime',
  42. capabilities: {
  43. streaming: true,
  44. tools: true,
  45. vision: false,
  46. jsonMode: false,
  47. systemPrompt: true,
  48. },
  49. create: vi.fn(),
  50. stream: vi.fn(async function *stream() {
  51. for (const event of events) yield event
  52. }),
  53. }
  54. }
  55. function emptyStreamingWithCreateFallback(response: ModelResponse): ModelClient {
  56. return {
  57. provider: 'test',
  58. requestStyle: 'custom-runtime',
  59. capabilities: {
  60. streaming: true,
  61. tools: true,
  62. vision: false,
  63. jsonMode: false,
  64. systemPrompt: true,
  65. },
  66. create: vi.fn(async () => response),
  67. stream: vi.fn(async function *stream() {
  68. yield { type: 'done', response: { finishReason: 'stop' } }
  69. }),
  70. }
  71. }
  72. describe('ekko-agent runtime', () => {
  73. it('runs a model request without tools', async () => {
  74. const client = modelClient(() => ({
  75. content: 'hello',
  76. model: 'test-model',
  77. }))
  78. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  79. const events: string[] = []
  80. const result = await runtime.run({
  81. messages: ['hi'],
  82. onEvent: event => events.push(event.type),
  83. })
  84. expect(result.output).toMatchObject({
  85. role: 'assistant',
  86. content: 'hello',
  87. model: 'test-model',
  88. })
  89. expect(result.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant'])
  90. expect(events).toEqual(['run.started', 'model.started', 'context.estimated', 'model.message', 'run.completed'])
  91. })
  92. it('forwards reasoning controls to model requests', async () => {
  93. const client = modelClient(request => {
  94. expect(request).toMatchObject({
  95. reasoningEffort: 'high',
  96. reasoningSummary: 'auto',
  97. })
  98. return { content: 'done' }
  99. })
  100. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  101. await runtime.run({
  102. messages: ['hi'],
  103. reasoningEffort: 'high',
  104. reasoningSummary: 'auto',
  105. })
  106. })
  107. it('estimates provider-visible context without starting a model run', async () => {
  108. const tools = new AgentToolRegistry()
  109. tools.register({
  110. definition: {
  111. name: 'estimate_only',
  112. description: 'Included in context estimates',
  113. parameters: { type: 'object' },
  114. },
  115. async execute() {
  116. return { ok: true, content: 'unused' }
  117. },
  118. })
  119. const client = modelClient(() => ({ content: 'must not run' }))
  120. const runtime = new AgentRuntime({ modelClient: client, tools })
  121. const estimate = await runtime.estimateContext({
  122. messages: ['hello'],
  123. metadata: { session_id: 'estimate-session' },
  124. })
  125. expect(estimate.messageCount).toBe(2)
  126. expect(estimate.toolCount).toBe(1)
  127. expect(estimate.systemPromptTokens).toBeGreaterThan(0)
  128. expect(estimate.messageTokens).toBeGreaterThan(0)
  129. expect(client.create).not.toHaveBeenCalled()
  130. expect(client.stream).not.toHaveBeenCalled()
  131. })
  132. it('emits one model usage event for each completed non-streaming model call', async () => {
  133. const client = modelClient(() => ({
  134. content: 'hello',
  135. usage: {
  136. inputTokens: 10,
  137. outputTokens: 4,
  138. cacheReadTokens: 6,
  139. reasoningTokens: 2,
  140. },
  141. }))
  142. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  143. const usageEvents: any[] = []
  144. await runtime.run({
  145. messages: ['hi'],
  146. onEvent: event => {
  147. if (event.type === 'model.usage') usageEvents.push(event)
  148. },
  149. })
  150. expect(usageEvents).toEqual([{
  151. type: 'model.usage',
  152. runId: expect.any(String),
  153. step: 1,
  154. usage: {
  155. inputTokens: 10,
  156. outputTokens: 4,
  157. cacheReadTokens: 6,
  158. reasoningTokens: 2,
  159. },
  160. }])
  161. })
  162. it('collapses repeated streaming usage updates into one event per model call', async () => {
  163. const client = streamingModelClient([
  164. { type: 'text-delta', text: 'ok' },
  165. { type: 'usage', usage: { inputTokens: 8, outputTokens: 1 } },
  166. { type: 'usage', usage: { inputTokens: 8, outputTokens: 2, cacheReadTokens: 5 } },
  167. { type: 'done', response: { finishReason: 'stop' } },
  168. ])
  169. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  170. const usageEvents: any[] = []
  171. await runtime.run({
  172. messages: ['hi'],
  173. onEvent: event => {
  174. if (event.type === 'model.usage') usageEvents.push(event)
  175. },
  176. })
  177. expect(usageEvents).toHaveLength(1)
  178. expect(usageEvents[0]).toMatchObject({
  179. step: 1,
  180. usage: { inputTokens: 8, outputTokens: 2, cacheReadTokens: 5 },
  181. })
  182. })
  183. it('emits model reasoning before the assistant message', async () => {
  184. const client = modelClient(() => ({
  185. content: 'answer',
  186. reasoning: 'thinking path',
  187. }))
  188. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  189. const events: string[] = []
  190. const reasoning: string[] = []
  191. const result = await runtime.run({
  192. messages: ['hi'],
  193. onEvent: event => {
  194. events.push(event.type)
  195. if (event.type === 'model.reasoning') reasoning.push(event.text)
  196. },
  197. })
  198. expect(result.output.reasoning).toBe('thinking path')
  199. expect(reasoning).toEqual(['thinking path'])
  200. expect(events).toEqual(['run.started', 'model.started', 'context.estimated', 'model.reasoning', 'model.message', 'run.completed'])
  201. })
  202. it('streams model text deltas before the final assistant message', async () => {
  203. const client = streamingModelClient([
  204. { type: 'text-delta', text: 'Hel' },
  205. { type: 'text-delta', text: 'lo' },
  206. { type: 'done', response: { finishReason: 'stop' } },
  207. ])
  208. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  209. const events: string[] = []
  210. const deltas: string[] = []
  211. const result = await runtime.run({
  212. messages: ['hi'],
  213. onEvent: event => {
  214. events.push(event.type)
  215. if (event.type === 'model.delta') deltas.push(event.text)
  216. },
  217. })
  218. expect(client.create).not.toHaveBeenCalled()
  219. expect(client.stream).toHaveBeenCalledTimes(1)
  220. expect(result.output.content).toBe('Hello')
  221. expect(deltas).toEqual(['Hel', 'lo'])
  222. expect(events).toEqual(['run.started', 'model.started', 'context.estimated', 'model.delta', 'model.delta', 'model.message', 'run.completed'])
  223. })
  224. it('falls back to non-streaming create when a provider stream returns no output', async () => {
  225. const client = emptyStreamingWithCreateFallback({ content: 'fallback answer', finishReason: 'stop' })
  226. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  227. const result = await runtime.run({ messages: ['hi'] })
  228. expect(result.output.content).toBe('fallback answer')
  229. expect(client.stream).toHaveBeenCalledTimes(1)
  230. expect(client.create).toHaveBeenCalledTimes(1)
  231. expect(vi.mocked(client.create).mock.calls[0]?.[0]).toMatchObject({ stream: false })
  232. })
  233. it('executes tool calls and continues the model loop', async () => {
  234. const echoTool: AgentTool = {
  235. definition: {
  236. name: 'echo',
  237. description: 'Echo text',
  238. parameters: { type: 'object' },
  239. },
  240. async execute(input) {
  241. return { ok: true, content: String(input.text || '') }
  242. },
  243. }
  244. const tools = new AgentToolRegistry()
  245. tools.register(echoTool)
  246. const client = modelClient((_request, call) => call === 1
  247. ? {
  248. content: '',
  249. toolCalls: [{ id: 'call_1', name: 'echo', arguments: { text: 'from-tool' } }],
  250. finishReason: 'tool_calls',
  251. }
  252. : { content: 'tool said from-tool', finishReason: 'stop' })
  253. const runtime = new AgentRuntime({ modelClient: client, tools, toolDelayMs: 0 })
  254. const result = await runtime.run({ messages: ['use echo'] })
  255. expect(result.output.content).toBe('tool said from-tool')
  256. expect(result.messages).toMatchObject([
  257. { role: 'system' },
  258. { role: 'user', content: 'use echo' },
  259. { role: 'assistant', toolCalls: [{ id: 'call_1', name: 'echo' }] },
  260. { role: 'tool', toolCallId: 'call_1', name: 'echo', content: 'from-tool' },
  261. { role: 'assistant', content: 'tool said from-tool' },
  262. ])
  263. expect(result.steps.map(step => step.type)).toEqual(['model', 'tool', 'model'])
  264. })
  265. it('waits for foreground delegated tasks and hides delegation from the child', async () => {
  266. const tools = new AgentToolRegistry()
  267. tools.register(new DelegateTaskTool())
  268. const requests: ModelRequest[] = []
  269. const client = modelClient((request, call) => {
  270. requests.push(request)
  271. if (call === 1) {
  272. return {
  273. content: '',
  274. toolCalls: [{
  275. id: 'delegate-1',
  276. name: 'delegate_task',
  277. arguments: {
  278. goal: 'Inspect the implementation',
  279. context: 'Focus on runtime.ts',
  280. mode: 'foreground',
  281. },
  282. }],
  283. finishReason: 'tool_calls',
  284. }
  285. }
  286. if (call === 2) return { content: 'Child inspection result', finishReason: 'stop' }
  287. return { content: 'Parent used the child result', finishReason: 'stop' }
  288. })
  289. const runtime = new AgentRuntime({ modelClient: client, tools, toolDelayMs: 0 })
  290. const events: any[] = []
  291. const result = await runtime.run({
  292. messages: ['Delegate this work'],
  293. metadata: { session_id: 'foreground-session' },
  294. onEvent: event => events.push(event),
  295. })
  296. expect(result.output.content).toBe('Parent used the child result')
  297. const childPrompt = requests[1].messages.find(message => message.role === 'user')?.content
  298. expect(childPrompt).toContain('Inspect the implementation')
  299. expect(childPrompt).toContain('Focus on runtime.ts')
  300. expect(requests[1].tools?.map(tool => tool.name)).not.toContain('delegate_task')
  301. expect(result.steps.find(step => step.type === 'tool')).toMatchObject({
  302. type: 'tool',
  303. toolName: 'delegate_task',
  304. result: {
  305. ok: true,
  306. data: {
  307. mode: 'foreground',
  308. status: 'completed',
  309. output: 'Child inspection result',
  310. },
  311. },
  312. })
  313. expect(events).toEqual(expect.arrayContaining([
  314. expect.objectContaining({
  315. type: 'subagent.start',
  316. goal: 'Inspect the implementation',
  317. background: false,
  318. }),
  319. expect.objectContaining({
  320. type: 'subagent.complete',
  321. status: 'completed',
  322. background: false,
  323. }),
  324. ]))
  325. })
  326. it('keeps background delegated tasks alive after the parent run completes', async () => {
  327. const tools = new AgentToolRegistry()
  328. tools.register(new DelegateTaskTool())
  329. let call = 0
  330. let finishChild!: (response: ModelResponse) => void
  331. const childResponse = new Promise<ModelResponse>((resolve) => {
  332. finishChild = resolve
  333. })
  334. const client: ModelClient = {
  335. provider: 'test',
  336. requestStyle: 'custom-runtime',
  337. capabilities: {
  338. streaming: false,
  339. tools: true,
  340. vision: false,
  341. jsonMode: false,
  342. systemPrompt: true,
  343. },
  344. create: vi.fn(async () => {
  345. call += 1
  346. if (call === 1) {
  347. return {
  348. content: '',
  349. toolCalls: [{
  350. id: 'delegate-bg',
  351. name: 'delegate_task',
  352. arguments: { goal: 'Run validation', mode: 'background' },
  353. }],
  354. finishReason: 'tool_calls',
  355. }
  356. }
  357. if (call === 2) return childResponse
  358. return { content: 'Background task started', finishReason: 'stop' }
  359. }),
  360. stream: vi.fn(),
  361. }
  362. const runtime = new AgentRuntime({ modelClient: client, tools, toolDelayMs: 0 })
  363. const events: any[] = []
  364. const result = await runtime.run({
  365. messages: ['Start it in the background'],
  366. metadata: { session_id: 'background-session' },
  367. onEvent: event => events.push(event),
  368. })
  369. expect(result.output.content).toBe('Background task started')
  370. expect(runtime.hasBackgroundTasks('background-session')).toBe(true)
  371. expect(events.some(event => event.type === 'subagent.complete')).toBe(false)
  372. finishChild({
  373. content: 'Validation passed',
  374. finishReason: 'stop',
  375. usage: {
  376. inputTokens: 12,
  377. outputTokens: 3,
  378. cacheReadTokens: 5,
  379. reasoningTokens: 2,
  380. },
  381. })
  382. await vi.waitFor(() => {
  383. expect(runtime.hasBackgroundTasks('background-session')).toBe(false)
  384. })
  385. expect(events).toEqual(expect.arrayContaining([
  386. expect.objectContaining({
  387. type: 'subagent.complete',
  388. status: 'completed',
  389. background: true,
  390. summary: 'Validation passed',
  391. output: 'Validation passed',
  392. childRunId: expect.any(String),
  393. apiCalls: 1,
  394. inputTokens: 12,
  395. outputTokens: 3,
  396. cacheReadTokens: 5,
  397. reasoningTokens: 2,
  398. }),
  399. ]))
  400. })
  401. it('aborts detached background delegated tasks by session', async () => {
  402. const tools = new AgentToolRegistry()
  403. tools.register(new DelegateTaskTool())
  404. let call = 0
  405. const client: ModelClient = {
  406. provider: 'test',
  407. requestStyle: 'custom-runtime',
  408. capabilities: {
  409. streaming: false,
  410. tools: true,
  411. vision: false,
  412. jsonMode: false,
  413. systemPrompt: true,
  414. },
  415. create: vi.fn(async (request) => {
  416. call += 1
  417. if (call === 1) {
  418. return {
  419. content: '',
  420. toolCalls: [{
  421. id: 'delegate-bg-abort',
  422. name: 'delegate_task',
  423. arguments: { goal: 'Wait indefinitely', mode: 'background' },
  424. }],
  425. finishReason: 'tool_calls',
  426. }
  427. }
  428. if (call === 2) {
  429. return new Promise<ModelResponse>((_resolve, reject) => {
  430. request.signal?.addEventListener('abort', () => {
  431. const error = new Error('Run aborted.')
  432. error.name = 'AbortError'
  433. reject(error)
  434. }, { once: true })
  435. })
  436. }
  437. return { content: 'Task started', finishReason: 'stop' }
  438. }),
  439. stream: vi.fn(),
  440. }
  441. const runtime = new AgentRuntime({ modelClient: client, tools, toolDelayMs: 0 })
  442. const events: any[] = []
  443. await runtime.run({
  444. messages: ['Start task'],
  445. metadata: { session_id: 'abort-background-session' },
  446. onEvent: event => events.push(event),
  447. })
  448. await expect(runtime.abortBackgroundTasks('abort-background-session')).resolves.toBe(1)
  449. expect(runtime.hasBackgroundTasks('abort-background-session')).toBe(false)
  450. expect(events).toEqual(expect.arrayContaining([
  451. expect.objectContaining({
  452. type: 'subagent.complete',
  453. status: 'interrupted',
  454. background: true,
  455. }),
  456. ]))
  457. })
  458. it('sanitizes base64 tool results before the next model request', async () => {
  459. const dataUrl = `data:image/png;base64,${Buffer.from('runtime-avatar').toString('base64')}`
  460. const avatarTool: AgentTool = {
  461. definition: {
  462. name: 'profiles_list',
  463. description: 'List profiles',
  464. parameters: { type: 'object' },
  465. },
  466. async execute() {
  467. return {
  468. ok: true,
  469. content: JSON.stringify({ profiles: [{ avatar: { type: 'image', dataUrl } }] }),
  470. }
  471. },
  472. }
  473. const tools = new AgentToolRegistry()
  474. tools.register(avatarTool)
  475. let assetUrl = ''
  476. const client = modelClient((request, call) => {
  477. if (call === 1) {
  478. return {
  479. content: '',
  480. toolCalls: [{ id: 'call_profiles', name: 'profiles_list', arguments: {} }],
  481. finishReason: 'tool_calls',
  482. }
  483. }
  484. const toolMessage = request.messages.find(message => message.role === 'tool')
  485. expect(toolMessage?.content).not.toContain('base64')
  486. assetUrl = JSON.parse(toolMessage?.content || '{}').profiles[0].avatar.dataUrl
  487. expect(assetUrl).toMatch(/^file:\/\//)
  488. return { content: 'done', finishReason: 'stop' }
  489. })
  490. try {
  491. const result = await new AgentRuntime({ modelClient: client, tools, toolDelayMs: 0 })
  492. .run({ messages: ['list profiles'] })
  493. expect(result.output.content).toBe('done')
  494. } finally {
  495. if (assetUrl) await rm(fileURLToPath(assetUrl), { force: true })
  496. }
  497. })
  498. it('discovers and executes MCP tools from the run tool context', async () => {
  499. const client = modelClient((request, call) => {
  500. if (call === 1) {
  501. expect(request.tools?.some(tool => tool.name === 'fake_echo')).toBe(true)
  502. return {
  503. content: '',
  504. toolCalls: [{ id: 'call_mcp', name: 'fake_echo', arguments: { text: 'hello' } }],
  505. finishReason: 'tool_calls',
  506. }
  507. }
  508. return { content: 'done', finishReason: 'stop' }
  509. })
  510. const runtime = new AgentRuntime({ modelClient: client, toolDelayMs: 0 })
  511. const result = await runtime.run({
  512. messages: ['use mcp'],
  513. toolContext: {
  514. mcpServers: {
  515. fake: {
  516. command: process.execPath,
  517. args: [join(process.cwd(), 'tests/fixtures/fake-mcp-server.cjs')],
  518. },
  519. },
  520. },
  521. })
  522. expect(result.messages).toMatchObject([
  523. { role: 'system' },
  524. { role: 'user', content: 'use mcp' },
  525. { role: 'assistant', toolCalls: [{ id: 'call_mcp', name: 'fake_echo' }] },
  526. { role: 'tool', toolCallId: 'call_mcp', name: 'fake_echo', content: 'mcp:hello' },
  527. { role: 'assistant', content: 'done' },
  528. ])
  529. })
  530. it('returns unknown tool failures as tool messages', async () => {
  531. const client = modelClient((_request, call) => call === 1
  532. ? {
  533. content: '',
  534. toolCalls: [{ id: 'call_missing', name: 'missing_tool', arguments: {} }],
  535. }
  536. : { content: 'handled missing tool' })
  537. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry(), maxSteps: 2, toolDelayMs: 0 })
  538. const result = await runtime.run({ messages: ['call missing'] })
  539. expect(result.messages[3]).toMatchObject({
  540. role: 'tool',
  541. toolCallId: 'call_missing',
  542. name: 'missing_tool',
  543. content: 'Unknown tool: missing_tool',
  544. })
  545. expect(result.output.content).toBe('handled missing tool')
  546. })
  547. it('stops after consecutive tool failures', async () => {
  548. const client = modelClient((_request, call) => ({
  549. content: '',
  550. toolCalls: [{ id: `call_missing_${call}`, name: 'missing_tool', arguments: {} }],
  551. }))
  552. const runtime = new AgentRuntime({
  553. modelClient: client,
  554. tools: new AgentToolRegistry(),
  555. maxConsecutiveToolFailures: 2,
  556. maxSteps: 10,
  557. toolDelayMs: 0,
  558. })
  559. const events: string[] = []
  560. const result = await runtime.run({
  561. messages: ['call missing repeatedly'],
  562. onEvent: event => events.push(event.type),
  563. })
  564. expect(result.output).toMatchObject({
  565. content: 'Stopped after 2 consecutive tool failures.',
  566. finishReason: 'tool_failure_limit',
  567. })
  568. expect(result.steps.filter(step => step.type === 'tool')).toHaveLength(2)
  569. expect(client.create).toHaveBeenCalledTimes(2)
  570. expect(events).toContain('run.tool_failure_limit')
  571. })
  572. it('passes abort signals into model requests', async () => {
  573. const controller = new AbortController()
  574. const client = modelClient((request) => {
  575. expect(request.signal).toBe(controller.signal)
  576. return { content: 'done' }
  577. })
  578. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  579. const result = await runtime.run({ messages: ['hi'], signal: controller.signal })
  580. expect(result.output.content).toBe('done')
  581. })
  582. it('stops before a model request when aborted', async () => {
  583. const controller = new AbortController()
  584. controller.abort()
  585. const client = modelClient(() => ({ content: 'should not run' }))
  586. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  587. await expect(runtime.run({ messages: ['hi'], signal: controller.signal })).rejects.toThrow('Run aborted.')
  588. expect(client.create).not.toHaveBeenCalled()
  589. })
  590. it('defaults maxSteps to 90', async () => {
  591. const client = modelClient(() => ({ content: 'done' }))
  592. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  593. const seen: number[] = []
  594. await runtime.run({
  595. messages: ['hi'],
  596. onEvent: event => {
  597. if (event.type === 'run.started') seen.push(event.maxSteps)
  598. },
  599. })
  600. expect(DEFAULT_AGENT_MAX_STEPS).toBe(90)
  601. expect(seen).toEqual([90])
  602. })
  603. it('retries each model step before continuing the loop', async () => {
  604. const client = modelClient((_request, call) => {
  605. if (call < 3) throw new Error(`temporary failure ${call}`)
  606. return { content: 'recovered' }
  607. })
  608. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  609. const retries: number[] = []
  610. const result = await runtime.run({
  611. messages: ['hi'],
  612. onEvent: event => {
  613. if (event.type === 'model.retry') retries.push(event.retry)
  614. },
  615. })
  616. expect(result.output.content).toBe('recovered')
  617. expect(client.create).toHaveBeenCalledTimes(3)
  618. expect(retries).toEqual([1, 2])
  619. })
  620. it('stops the run after three failed model retries', async () => {
  621. const client = modelClient(() => {
  622. throw new Error('still failing')
  623. })
  624. const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() })
  625. const events: string[] = []
  626. await expect(runtime.run({
  627. messages: ['hi'],
  628. onEvent: event => events.push(event.type),
  629. })).rejects.toThrow('still failing')
  630. expect(DEFAULT_AGENT_MODEL_MAX_RETRIES).toBe(3)
  631. expect(client.create).toHaveBeenCalledTimes(4)
  632. expect(events.filter(event => event === 'model.retry')).toHaveLength(3)
  633. expect(events.at(-1)).toBe('run.failed')
  634. })
  635. it('builds a system prompt from runtime context and user system messages without skill instructions', async () => {
  636. const requests: ModelRequest[] = []
  637. const client = modelClient((request) => {
  638. requests.push(request)
  639. return { content: 'ok' }
  640. })
  641. const runtime = new AgentRuntime({
  642. modelClient: client,
  643. tools: new AgentToolRegistry(),
  644. systemPrompt: 'Base prompt.',
  645. runtimeInstructions: ['Use tools carefully.'],
  646. skills: [{
  647. id: 'review',
  648. name: 'Review',
  649. instructions: 'Review for correctness.',
  650. }],
  651. })
  652. await runtime.run({
  653. messages: [
  654. { role: 'system', content: 'User system.' },
  655. { role: 'user', content: 'Go' },
  656. ],
  657. model: 'test-model',
  658. })
  659. expect(requests[0].messages[0].content).toContain('Base prompt.')
  660. expect(requests[0].messages[0].content).toContain('Use tools carefully.')
  661. expect(requests[0].messages[0].content).not.toContain('Review for correctness.')
  662. expect(requests[0].messages[0].content).not.toContain('## Skills')
  663. expect(requests[0].messages[0].content).toContain('User system.')
  664. expect(requests[0].messages[0].content).toContain('## Runtime Context\nprovider: test\nmodel: test-model')
  665. expect(requests[0].messages.filter(message => message.role === 'system')).toHaveLength(1)
  666. })
  667. it('does not inject skill instructions when all tools are disabled', async () => {
  668. const listTools = vi.fn(async () => [])
  669. const tools = new AgentToolRegistry()
  670. tools.registerProvider({ id: 'dynamic', listTools })
  671. const client = modelClient((request) => {
  672. expect(request.tools).toBeUndefined()
  673. expect(request.messages[0].content).not.toContain('Keep this skill instruction.')
  674. return { content: 'ok' }
  675. })
  676. await new AgentRuntime({
  677. modelClient: client,
  678. tools,
  679. toolsEnabled: false,
  680. skills: [{
  681. id: 'kept-skill',
  682. name: 'Kept Skill',
  683. instructions: 'Keep this skill instruction.',
  684. }],
  685. }).run({ messages: ['hi'] })
  686. expect(listTools).not.toHaveBeenCalled()
  687. })
  688. it('injects the skill discovery constraint when both skill tools are available', async () => {
  689. const root = await mkdtemp(join(tmpdir(), 'ekko-runtime-skills-'))
  690. const skillDirectory = join(root, 'skills')
  691. const client = modelClient((request) => {
  692. expect(request.tools?.map(tool => tool.name)).toEqual(expect.arrayContaining(['skill_list', 'skill_view', 'skill_manage']))
  693. expect(request.messages[0].content).toContain('## Skill Discovery')
  694. expect(request.messages[0].content).toContain('call skill_list before proceeding')
  695. expect(request.messages[0].content).toContain('## Skill Evolution')
  696. return { content: 'ok' }
  697. })
  698. try {
  699. await new AgentRuntime({ modelClient: client, skillDirectory }).run({ messages: ['hi'] })
  700. } finally {
  701. await rm(root, { recursive: true, force: true })
  702. }
  703. })
  704. it('reviews a tool-heavy turn in the background with only skill tools', async () => {
  705. const root = await mkdtemp(join(tmpdir(), 'ekko-skill-review-'))
  706. const skillDirectory = join(root, 'skills')
  707. const workspaceRoot = join(root, 'workspace')
  708. await mkdir(workspaceRoot, { recursive: true })
  709. await writeFile(join(workspaceRoot, 'input.txt'), 'reusable evidence')
  710. let mainCalls = 0
  711. let reviewCalls = 0
  712. const reviewRequests: ModelRequest[] = []
  713. const usageEvents: unknown[] = []
  714. const eventTypes: string[] = []
  715. const client: ModelClient = {
  716. provider: 'test',
  717. requestStyle: 'custom-runtime',
  718. capabilities: {
  719. streaming: false,
  720. tools: true,
  721. vision: false,
  722. jsonMode: false,
  723. systemPrompt: true,
  724. },
  725. create: vi.fn(async (request: ModelRequest) => {
  726. if (request.metadata?.purpose === 'ekko-skill-review') {
  727. reviewRequests.push(request)
  728. reviewCalls += 1
  729. if (reviewCalls === 1) {
  730. return {
  731. content: '',
  732. toolCalls: [{ id: 'review-list', name: 'skill_list', arguments: {} }],
  733. finishReason: 'tool_calls',
  734. }
  735. }
  736. if (reviewCalls === 2) {
  737. return {
  738. content: '',
  739. toolCalls: [{
  740. id: 'review-create',
  741. name: 'skill_manage',
  742. arguments: {
  743. action: 'create',
  744. name: 'reusable-verification',
  745. content: [
  746. '---',
  747. 'name: reusable-verification',
  748. 'description: Verify recurring changes consistently.',
  749. '---',
  750. '# Reusable Verification',
  751. '## Procedure',
  752. 'Run the focused check and verify its output.',
  753. '',
  754. ].join('\n'),
  755. },
  756. }],
  757. finishReason: 'tool_calls',
  758. usage: { inputTokens: 20, outputTokens: 5 },
  759. }
  760. }
  761. return { content: 'Done.', usage: { inputTokens: 12, outputTokens: 2 } }
  762. }
  763. mainCalls += 1
  764. return mainCalls === 1
  765. ? {
  766. content: '',
  767. toolCalls: [{ id: 'main-read', name: 'read_file', arguments: { path: 'input.txt' } }],
  768. finishReason: 'tool_calls',
  769. }
  770. : { content: 'Main answer.' }
  771. }),
  772. stream: vi.fn(),
  773. }
  774. const runtime = new AgentRuntime({
  775. modelClient: client,
  776. skillDirectory,
  777. skillReviewEveryToolCalls: 1,
  778. toolDelayMs: 0,
  779. })
  780. try {
  781. const result = await runtime.run({
  782. messages: ['Use the input and finish the task.'],
  783. metadata: { session_id: 'review-session' },
  784. toolContext: { workspaceRoot },
  785. onSkillReviewUsage: event => {
  786. usageEvents.push(event)
  787. throw new Error('observer failure')
  788. },
  789. onEvent: event => eventTypes.push(event.type),
  790. })
  791. expect(result.output.content).toBe('Main answer.')
  792. await runtime.drainSkillReviews()
  793. expect(reviewRequests[0].tools?.map(tool => tool.name).sort()).toEqual([
  794. 'skill_list',
  795. 'skill_manage',
  796. 'skill_view',
  797. ])
  798. expect(reviewRequests[0].messages[0].content).toContain('background procedural-learning reviewer')
  799. expect(reviewRequests[0].messages[1].content).toContain('reusable evidence')
  800. await expect(readFile(
  801. join(skillDirectory, 'reusable-verification', 'SKILL.md'),
  802. 'utf8',
  803. )).resolves.toContain('Run the focused check')
  804. expect(usageEvents).toHaveLength(2)
  805. expect(eventTypes.indexOf('run.completed')).toBeLessThan(eventTypes.indexOf('skill.review.started'))
  806. expect(eventTypes).toContain('skill.review.completed')
  807. } finally {
  808. await rm(root, { recursive: true, force: true })
  809. }
  810. })
  811. it('disables constructor and per-run skills without disabling regular tools', async () => {
  812. const regularTool: AgentTool = {
  813. definition: { name: 'regular_tool', parameters: { type: 'object' } },
  814. async execute() {
  815. return { ok: true, content: 'regular' }
  816. },
  817. }
  818. const skillTool: AgentTool = {
  819. definition: { name: 'skill_tool', parameters: { type: 'object' } },
  820. async execute() {
  821. return { ok: true, content: 'skill' }
  822. },
  823. }
  824. const tools = new AgentToolRegistry()
  825. tools.register(regularTool)
  826. const client = modelClient((request) => {
  827. expect(request.tools?.map(tool => tool.name)).toEqual(['regular_tool'])
  828. expect(request.messages[0].content).not.toContain('Disabled constructor skill.')
  829. expect(request.messages[0].content).not.toContain('Disabled run skill.')
  830. return { content: 'ok' }
  831. })
  832. const runtime = new AgentRuntime({
  833. modelClient: client,
  834. tools,
  835. skillsEnabled: false,
  836. skills: [{
  837. id: 'constructor-skill',
  838. name: 'Constructor Skill',
  839. instructions: 'Disabled constructor skill.',
  840. tools: [skillTool],
  841. }],
  842. })
  843. runtime.registerSkill({
  844. id: 'registered-skill',
  845. name: 'Registered Skill',
  846. instructions: 'Disabled registered skill.',
  847. tools: [skillTool],
  848. })
  849. await runtime.run({
  850. messages: ['hi'],
  851. skills: [{
  852. id: 'run-skill',
  853. name: 'Run Skill',
  854. instructions: 'Disabled run skill.',
  855. tools: [skillTool],
  856. }],
  857. })
  858. })
  859. it('refreshes dynamic tool providers before running', async () => {
  860. const providerTool: AgentTool = {
  861. definition: { name: 'provided_tool', parameters: { type: 'object' } },
  862. async execute() {
  863. return { ok: true, content: 'provided' }
  864. },
  865. }
  866. const provider: AgentToolProvider = {
  867. id: 'test-provider',
  868. async listTools() {
  869. return [providerTool]
  870. },
  871. }
  872. const tools = new AgentToolRegistry()
  873. tools.registerProvider(provider)
  874. const client = modelClient((request) => {
  875. expect(request.tools?.map(tool => tool.name)).toContain('provided_tool')
  876. return { content: 'ok' }
  877. })
  878. await new AgentRuntime({ modelClient: client, tools }).run({ messages: ['hi'] })
  879. })
  880. it('stores model context by session and sends it on follow-up runs', async () => {
  881. const requests: ModelRequest[] = []
  882. const client = modelClient((request, call) => {
  883. requests.push(request)
  884. return {
  885. content: `ok-${call}`,
  886. context: { responseId: `resp-${call}` },
  887. }
  888. })
  889. const runtime = new AgentRuntime({ modelClient: client })
  890. const first = await runtime.run({
  891. messages: ['first'],
  892. metadata: { session_id: 'session-a' },
  893. })
  894. const second = await runtime.run({
  895. messages: ['second'],
  896. metadata: { session_id: 'session-a' },
  897. })
  898. await runtime.run({
  899. messages: ['other'],
  900. metadata: { session_id: 'session-b' },
  901. })
  902. expect(first.context).toEqual({ responseId: 'resp-1' })
  903. expect(second.context).toEqual({ responseId: 'resp-2' })
  904. expect(requests[0].context).toBeUndefined()
  905. expect(requests[1].context).toEqual({ responseId: 'resp-1' })
  906. expect(requests[2].context).toBeUndefined()
  907. })
  908. it('buildSystemPrompt omits structured tool descriptions', () => {
  909. const prompt = buildSystemPrompt({
  910. basePrompt: 'Base',
  911. })
  912. expect(prompt).toContain('Base')
  913. expect(prompt).not.toContain('Available Tools')
  914. expect(prompt).not.toContain('read_file')
  915. })
  916. it('buildSystemPrompt includes provider and model in runtime context', () => {
  917. const prompt = buildSystemPrompt({
  918. basePrompt: 'Base',
  919. context: {
  920. provider: 'openrouter',
  921. model: 'anthropic/claude-sonnet-4',
  922. workspaceRoot: '/tmp/workspace',
  923. },
  924. })
  925. expect(prompt).toContain([
  926. '## Runtime Context',
  927. 'provider: openrouter',
  928. 'model: anthropic/claude-sonnet-4',
  929. 'workspaceRoot: /tmp/workspace',
  930. ].join('\n'))
  931. })
  932. it('buildSystemPrompt adds the on-demand skill discovery constraint', () => {
  933. const prompt = buildSystemPrompt({
  934. basePrompt: 'Base',
  935. skillDiscoveryEnabled: true,
  936. skillManagementEnabled: true,
  937. })
  938. expect(prompt).toContain('## Skill Discovery')
  939. expect(prompt).toContain('call skill_list before proceeding')
  940. expect(prompt).toContain('call skill_view with its exact name')
  941. expect(prompt).toContain('## Skill Evolution')
  942. expect(prompt).toContain('Prefer a small patch over a full edit.')
  943. expect(prompt).not.toContain('## Skills')
  944. })
  945. it('buildSystemPrompt always includes Ekko image and file output guidance', () => {
  946. const prompt = buildSystemPrompt({ basePrompt: 'Base' })
  947. expect(prompt).toContain('## Image and File Output')
  948. expect(prompt).toContain('![description](/absolute/path/image.png)')
  949. expect(prompt).toContain('![description](<C:/absolute/path/image.png>)')
  950. expect(prompt).toContain('Do not use relative paths or `file://` URLs.')
  951. })
  952. it('buildSystemPrompt requires dependency preflight before tool execution', () => {
  953. const prompt = buildSystemPrompt({ basePrompt: 'Base' })
  954. expect(prompt).toContain('## Tool Execution')
  955. expect(prompt).toContain('prerequisites named by a Skill as requirements, not proof that they are installed')
  956. expect(prompt).toContain('perform a lightweight availability check')
  957. expect(prompt).toContain('prefer a compatible installed or built-in alternative')
  958. })
  959. })
粤ICP备19079148号