agent-execution.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import { describe, expect, it } from 'vitest'
  2. import { Context, FiberState, type Fiber } from 'cordis'
  3. import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
  4. import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
  5. import type { AgentExecutionService } from '@deepseek-ai/dsh-agent-execution'
  6. import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  7. import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
  8. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  9. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  10. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  11. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  12. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  13. interface Harness {
  14. ctx: Context
  15. providerFiber: Fiber
  16. loopFiber: Fiber
  17. }
  18. async function harness(adapter: LlmAdapter): Promise<Harness> {
  19. const ctx = new Context()
  20. await ctx.plugin(LlmService)
  21. await ctx.plugin(SessionStore)
  22. await ctx.plugin(SystemPrompt)
  23. await ctx.plugin(ToolRegistry)
  24. await ctx.plugin(AgentRegistry)
  25. const providerFiber = await ctx.plugin(AgentExecutionProvider)
  26. const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
  27. ctx.llm.registerAdapter(['mock'], adapter)
  28. return { ctx, providerFiber, loopFiber }
  29. }
  30. function waitForIdle(ctx: Context, agent: ReactLoopAgent | Agent): Promise<void> {
  31. return new Promise((resolve) => {
  32. const dispose = ctx.on('agent/status', (subject, status) => {
  33. if (subject === agent && status === 'idle') {
  34. dispose()
  35. resolve()
  36. }
  37. })
  38. })
  39. }
  40. function send(agent: Agent, text: string): void {
  41. agent.send([{ type: 'text', text }])
  42. }
  43. /** Adapter that holds both drivers at the same awaited continuation. */
  44. class OverlapAdapter extends LlmAdapter {
  45. private readonly bothStarted = Promise.withResolvers<boolean>()
  46. private starts = 0
  47. readonly observations: { sessionId: SessionId | undefined; before: Agent; after: Agent }[] = []
  48. constructor(private readonly ctx: Context) {
  49. super()
  50. }
  51. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  52. const before = this.ctx.agentExecution.require().agent
  53. this.starts += 1
  54. if (this.starts === 2) this.bothStarted.resolve(true)
  55. await this.bothStarted.promise
  56. await Promise.resolve()
  57. const after = this.ctx.agentExecution.require().agent
  58. this.observations.push({ sessionId: options.sessionId, before, after })
  59. yield* textResponse('done')
  60. }
  61. }
  62. /** Test-only transport that materializes ambient identity at its request boundary. */
  63. class TestCapabilityTransport {
  64. readonly requests: { path: string; headers: Record<string, string> }[] = []
  65. constructor(private readonly execution: AgentExecutionService) {}
  66. async request(path: string): Promise<Record<string, string>> {
  67. await Promise.resolve()
  68. const headers = {
  69. 'X-Harness-Session-Id': this.execution.require().agent.session.id,
  70. }
  71. this.requests.push({ path, headers })
  72. return headers
  73. }
  74. }
  75. /** Adapter whose first call waits for cancellation and whose later calls complete. */
  76. class ReloadAdapter extends LlmAdapter {
  77. readonly firstStarted = Promise.withResolvers<boolean>()
  78. firstAgentDuringAbort: Agent | undefined
  79. laterAgent: Agent | undefined
  80. calls = 0
  81. execution: AgentExecutionService | undefined
  82. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  83. const execution = this.execution
  84. if (execution === undefined) throw new Error('execution service missing')
  85. this.calls += 1
  86. if (this.calls === 1) {
  87. this.firstStarted.resolve(true)
  88. try {
  89. await new Promise<void>((_resolve, reject) => {
  90. const abort = (): void => { reject(new Error('aborted')) }
  91. if (options.signal?.aborted === true) abort()
  92. else options.signal?.addEventListener('abort', abort, { once: true })
  93. })
  94. } catch (error: unknown) {
  95. await Promise.resolve()
  96. this.firstAgentDuringAbort = execution.require().agent
  97. throw error
  98. }
  99. return
  100. }
  101. await Promise.resolve()
  102. this.laterAgent = execution.require().agent
  103. yield* textResponse('reloaded')
  104. }
  105. }
  106. describe('AgentLoop execution context', () => {
  107. it('keeps overlapping driver continuations bound to their exact Agents', async () => {
  108. const ctx = new Context()
  109. const adapter = new OverlapAdapter(ctx)
  110. await ctx.plugin(LlmService)
  111. await ctx.plugin(SessionStore)
  112. await ctx.plugin(SystemPrompt)
  113. await ctx.plugin(ToolRegistry)
  114. await ctx.plugin(AgentRegistry)
  115. await ctx.plugin(AgentExecutionProvider)
  116. await ctx.plugin(AgentLoop, { agents: [] })
  117. ctx.llm.registerAdapter(['mock'], adapter)
  118. const a = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
  119. const b = ctx.agentLoop.create(AgentId('b'), { provider: 'mock', model: 'mock' })
  120. const idleA = waitForIdle(ctx, a)
  121. const idleB = waitForIdle(ctx, b)
  122. send(a, 'a')
  123. send(b, 'b')
  124. await Promise.all([idleA, idleB])
  125. expect(adapter.observations).toHaveLength(2)
  126. expect(adapter.observations).toEqual(expect.arrayContaining([
  127. { sessionId: a.session.id, before: a, after: a },
  128. { sessionId: b.session.id, before: b, after: b },
  129. ]))
  130. expect(ctx.agentExecution.current()).toBeUndefined()
  131. await ctx.fiber.dispose()
  132. })
  133. it('keeps ALS identity minimal while one explicit signal spans each turn seam', async () => {
  134. const adapter = new MockAdapter([
  135. toolCallResponse('observe-call', 'observe', {}),
  136. textResponse('first done'),
  137. textResponse('second done'),
  138. ])
  139. const { ctx } = await harness(adapter)
  140. const agent = ctx.agentLoop.create(AgentId('signal-owner'), { provider: 'mock', model: 'mock' })
  141. let signals: AbortSignal[] = []
  142. const capture = (signal: AbortSignal | undefined): void => {
  143. if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
  144. const execution = ctx.agentExecution.require()
  145. expect(Object.keys(execution)).toEqual(['agent'])
  146. expect(execution.agent).toBe(agent)
  147. signals.push(signal)
  148. }
  149. ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
  150. if (context.agent === agent) capture(context.signal)
  151. return next()
  152. })
  153. ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
  154. if (subject === agent) capture(signal)
  155. return next()
  156. })
  157. ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
  158. if (subject === agent) capture(signal)
  159. return next()
  160. })
  161. ctx.on('agent/pre-step', (subject, _turn, _step, _system, _prefix, signal) => {
  162. if (subject === agent) capture(signal)
  163. })
  164. ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
  165. if (subject === agent) capture(signal)
  166. return next()
  167. })
  168. ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
  169. if (subject === agent) capture(signal)
  170. return next()
  171. })
  172. ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
  173. if (subject === agent) capture(signal)
  174. return next()
  175. })
  176. ctx.on('agent/turn-stop', (subject, _turn, signal) => {
  177. if (subject === agent) capture(signal)
  178. })
  179. ctx.tools.register(defineTool({
  180. name: 'observe',
  181. description: 'observe explicit turn state',
  182. parameters: {},
  183. execute: async (_args, exec) => {
  184. capture(exec.signal)
  185. return [{ type: 'text', text: 'observed' }]
  186. },
  187. }))
  188. const firstIdle = waitForIdle(ctx, agent)
  189. send(agent, 'first')
  190. await firstIdle
  191. const firstSignal = signals[0]
  192. expect(firstSignal).toBeDefined()
  193. expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
  194. signals = []
  195. const secondIdle = waitForIdle(ctx, agent)
  196. send(agent, 'second')
  197. await secondIdle
  198. const secondSignal = signals[0]
  199. expect(secondSignal).toBeDefined()
  200. expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
  201. expect(secondSignal).not.toBe(firstSignal)
  202. expect(ctx.agentExecution.current()).toBeUndefined()
  203. await ctx.fiber.dispose()
  204. })
  205. it('keeps child setup under the parent boundary, switches for the child driver, then restores the parent', async () => {
  206. const adapter = new MockAdapter([
  207. toolCallResponse('spawn', 'spawn-child', {}),
  208. toolCallResponse('observe', 'observe-child', {}),
  209. textResponse('child done'),
  210. textResponse('parent done'),
  211. ])
  212. const { ctx } = await harness(adapter)
  213. let parentDuringSetup: Agent | undefined
  214. let explicitChild: Agent | undefined
  215. let childDuringDriver: Agent | undefined
  216. let parentAfterChild: Agent | undefined
  217. let child: Agent | undefined
  218. ctx.tools.register(defineTool({
  219. name: 'spawn-child',
  220. description: 'create one child agent',
  221. parameters: {},
  222. execute: async (_args, exec) => {
  223. if (exec.agent === undefined) throw new Error('parent agent missing')
  224. const handle = await exec.agent.ctx.agents.create({
  225. agentId: AgentId('child'),
  226. sessionId: SessionId('child-session'),
  227. agentOptions: { provider: 'mock', model: 'mock' },
  228. setup: (agentCtx) => {
  229. parentDuringSetup = ctx.agentExecution.require().agent
  230. explicitChild = agentCtx.agent
  231. agentCtx.tools.register(defineTool({
  232. name: 'observe-child',
  233. description: 'observe child execution identity',
  234. parameters: {},
  235. execute: async () => {
  236. await Promise.resolve()
  237. childDuringDriver = ctx.agentExecution.require().agent
  238. return [{ type: 'text', text: 'observed' }]
  239. },
  240. }))
  241. },
  242. })
  243. child = handle.agent
  244. send(handle.agent, 'run child')
  245. await handle.agent.whenIdle()
  246. parentAfterChild = ctx.agentExecution.require().agent
  247. await handle.dispose()
  248. return [{ type: 'text', text: 'child completed' }]
  249. },
  250. }))
  251. const parentHandle = await ctx.agents.create({
  252. agentId: AgentId('parent'),
  253. sessionId: SessionId('parent-session'),
  254. agentOptions: { provider: 'mock', model: 'mock' },
  255. })
  256. const idle = waitForIdle(ctx, parentHandle.agent)
  257. send(parentHandle.agent, 'spawn')
  258. await idle
  259. expect(parentDuringSetup).toBe(parentHandle.agent)
  260. expect(explicitChild).toBe(child)
  261. expect(childDuringDriver).toBe(child)
  262. expect(parentAfterChild).toBe(parentHandle.agent)
  263. expect(ctx.agentExecution.current()).toBeUndefined()
  264. await parentHandle.dispose()
  265. await ctx.fiber.dispose()
  266. })
  267. it('keeps agentless direct tools ambient-free and builds trusted transport headers internally', async () => {
  268. const adapter = new MockAdapter([
  269. toolCallResponse('capability', 'capability-request', { path: '/v1/capability' }),
  270. textResponse('done'),
  271. ])
  272. const { ctx } = await harness(adapter)
  273. const transport = new TestCapabilityTransport(ctx.agentExecution)
  274. let directAmbient: Agent | undefined
  275. let captured: Agent | undefined
  276. ctx.tools.register(defineTool({
  277. name: 'agentless-probe',
  278. description: 'observe an agentless call',
  279. parameters: {},
  280. execute: async () => {
  281. await Promise.resolve()
  282. directAmbient = ctx.agentExecution.current()?.agent
  283. return [{ type: 'text', text: 'ok' }]
  284. },
  285. }))
  286. ctx.tools.register(defineTool({
  287. name: 'capability-request',
  288. description: 'call the test capability transport',
  289. parameters: { path: { type: 'string' } },
  290. execute: async (args) => {
  291. captured = ctx.agentExecution.require().agent
  292. const path = (args as { path: string }).path
  293. const headers = await transport.request(path)
  294. return [{ type: 'text', text: JSON.stringify(headers) }]
  295. },
  296. }))
  297. const direct = await ctx.tools.execute({
  298. callId: CallId('direct'),
  299. name: 'agentless-probe',
  300. arguments: {},
  301. })
  302. expect(direct.isError).toBe(false)
  303. expect(directAmbient).toBeUndefined()
  304. const handle = await ctx.agents.create({
  305. agentId: AgentId('transport'),
  306. sessionId: SessionId('transport-session'),
  307. agentOptions: { provider: 'mock', model: 'mock' },
  308. })
  309. const idle = waitForIdle(ctx, handle.agent)
  310. send(handle.agent, 'call transport')
  311. await idle
  312. expect(transport.requests).toEqual([{
  313. path: '/v1/capability',
  314. headers: { 'X-Harness-Session-Id': 'transport-session' },
  315. }])
  316. const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request')
  317. expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i)
  318. const call = handle.agent.session.events.find(event => event.type === 'tool/call')
  319. expect(call?.type === 'tool/call' ? call.data.arguments : undefined)
  320. .toBe(JSON.stringify({ path: '/v1/capability' }))
  321. expect(captured).toBe(handle.agent)
  322. await handle.dispose()
  323. expect(captured?.status).toBe('disposed')
  324. expect(ctx.agentExecution.current()).toBeUndefined()
  325. await ctx.fiber.dispose()
  326. })
  327. it('keeps AgentLoop inactive until the mandatory provider appears', async () => {
  328. const ctx = new Context()
  329. await ctx.plugin(LlmService)
  330. await ctx.plugin(SessionStore)
  331. await ctx.plugin(SystemPrompt)
  332. await ctx.plugin(ToolRegistry)
  333. await ctx.plugin(AgentRegistry)
  334. const loopFiber = ctx.plugin(AgentLoop, { agents: [] })
  335. await Promise.resolve()
  336. expect(loopFiber.state).toBe(FiberState.PENDING)
  337. await ctx.plugin(AgentExecutionProvider)
  338. await loopFiber
  339. expect(loopFiber.state).toBe(FiberState.ACTIVE)
  340. await ctx.fiber.dispose()
  341. })
  342. it('drains the old driver before disabling ALS during provider restart', async () => {
  343. const ctx = new Context()
  344. const adapter = new ReloadAdapter()
  345. const { providerFiber, loopFiber } = await (async (): Promise<Harness> => {
  346. await ctx.plugin(LlmService)
  347. await ctx.plugin(SessionStore)
  348. await ctx.plugin(SystemPrompt)
  349. await ctx.plugin(ToolRegistry)
  350. await ctx.plugin(AgentRegistry)
  351. const mountedProvider = await ctx.plugin(AgentExecutionProvider)
  352. const mountedLoop = await ctx.plugin(AgentLoop, { agents: [] })
  353. ctx.llm.registerAdapter(['mock'], adapter)
  354. return { ctx, providerFiber: mountedProvider, loopFiber: mountedLoop }
  355. })()
  356. const oldService = ctx.agentExecution
  357. adapter.execution = oldService
  358. const oldHandle = await ctx.agents.create({
  359. agentId: AgentId('before-restart'),
  360. sessionId: SessionId('before-restart-session'),
  361. agentOptions: { provider: 'mock', model: 'mock' },
  362. })
  363. const oldAgent = oldHandle.agent
  364. send(oldAgent, 'block')
  365. await adapter.firstStarted.promise
  366. await providerFiber.restart()
  367. await loopFiber.await()
  368. expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id)
  369. expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session)
  370. expect(oldAgent.status).toBe('disposed')
  371. expect(() => oldService.current()).toThrow('agent execution service is disposed')
  372. expect(ctx.agentExecution).not.toBe(oldService)
  373. adapter.execution = ctx.agentExecution
  374. const newHandle = await ctx.agents.create({
  375. agentId: AgentId('after-restart'),
  376. sessionId: SessionId('after-restart-session'),
  377. agentOptions: { provider: 'mock', model: 'mock' },
  378. })
  379. const newAgent = newHandle.agent
  380. const idle = waitForIdle(ctx, newAgent)
  381. send(newAgent, 'continue')
  382. await idle
  383. expect(adapter.laterAgent?.id).toBe(newAgent.id)
  384. expect(adapter.laterAgent?.session).toBe(newAgent.session)
  385. await ctx.fiber.dispose()
  386. })
  387. it('keeps ALS readable while root disposal drains sibling AgentLoop fibers', async () => {
  388. const ctx = new Context()
  389. const adapter = new ReloadAdapter()
  390. await ctx.plugin(LlmService)
  391. await ctx.plugin(SessionStore)
  392. await ctx.plugin(SystemPrompt)
  393. await ctx.plugin(ToolRegistry)
  394. await ctx.plugin(AgentRegistry)
  395. await ctx.plugin(AgentExecutionProvider)
  396. await ctx.plugin(AgentLoop, { agents: [] })
  397. ctx.llm.registerAdapter(['mock'], adapter)
  398. const service = ctx.agentExecution
  399. adapter.execution = service
  400. const handle = await ctx.agents.create({
  401. agentId: AgentId('root-dispose'),
  402. sessionId: SessionId('root-dispose-session'),
  403. agentOptions: { provider: 'mock', model: 'mock' },
  404. })
  405. const agent = handle.agent
  406. send(agent, 'block')
  407. await adapter.firstStarted.promise
  408. await ctx.fiber.dispose()
  409. expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id)
  410. expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session)
  411. expect(agent.status).toBe('disposed')
  412. expect(() => service.current()).toThrow('agent execution service is disposed')
  413. })
  414. })