agent-initiator.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. import { describe, expect, it } from 'vitest'
  2. import { Context, type Fiber } from 'cordis'
  3. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  4. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  5. import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
  6. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  9. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  10. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  11. interface Harness {
  12. ctx: Context
  13. agentsFiber: Fiber
  14. loopFiber: Fiber
  15. }
  16. async function harness(adapter: LlmAdapter): Promise<Harness> {
  17. const ctx = new Context()
  18. await ctx.plugin(LlmService)
  19. await ctx.plugin(SessionStore)
  20. await ctx.plugin(SystemPrompt)
  21. await ctx.plugin(ToolRegistry)
  22. const agentsFiber = await ctx.plugin(AgentRegistry)
  23. const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
  24. ctx.llm.registerAdapter(['mock'], adapter)
  25. return { ctx, agentsFiber, loopFiber }
  26. }
  27. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  28. return new Promise((resolve) => {
  29. const dispose = ctx.on('agent/status', (subject, status) => {
  30. if (subject === agent && status === 'idle') {
  31. dispose()
  32. resolve()
  33. }
  34. })
  35. })
  36. }
  37. function send(agent: Agent, text: string): void {
  38. agent.send([{ type: 'text', text }])
  39. }
  40. /** Adapter that holds both drivers at the same awaited continuation. */
  41. class OverlapAdapter extends LlmAdapter {
  42. private readonly bothStarted = Promise.withResolvers<boolean>()
  43. private starts = 0
  44. readonly observations: { sessionId: SessionId | undefined; before: Agent; after: Agent }[] = []
  45. constructor(private readonly ctx: Context) {
  46. super()
  47. }
  48. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  49. const before = this.ctx.agents.requireInitiator()
  50. this.starts += 1
  51. if (this.starts === 2) this.bothStarted.resolve(true)
  52. await this.bothStarted.promise
  53. await Promise.resolve()
  54. const after = this.ctx.agents.requireInitiator()
  55. this.observations.push({ sessionId: options.sessionId, before, after })
  56. yield* textResponse('done')
  57. }
  58. }
  59. /** Test-only transport that materializes ambient identity at its request boundary. */
  60. class TestCapabilityTransport {
  61. readonly requests: { path: string; headers: Record<string, string> }[] = []
  62. constructor(private readonly agents: AgentRegistry) {}
  63. async request(path: string): Promise<Record<string, string>> {
  64. await Promise.resolve()
  65. const headers = {
  66. 'X-Harness-Session-Id': this.agents.requireInitiator().session.id,
  67. }
  68. this.requests.push({ path, headers })
  69. return headers
  70. }
  71. }
  72. /** Adapter whose first call waits for cancellation and whose later calls complete. */
  73. class ReloadAdapter extends LlmAdapter {
  74. readonly firstStarted = Promise.withResolvers<boolean>()
  75. firstAgentDuringAbort: Agent | undefined
  76. laterAgent: Agent | undefined
  77. calls = 0
  78. agents: AgentRegistry | undefined
  79. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  80. const agents = this.agents
  81. if (agents === undefined) throw new Error('agent service missing')
  82. this.calls += 1
  83. if (this.calls === 1) {
  84. this.firstStarted.resolve(true)
  85. try {
  86. await new Promise<void>((_resolve, reject) => {
  87. const abort = (): void => { reject(new Error('aborted')) }
  88. if (options.signal?.aborted === true) abort()
  89. else options.signal?.addEventListener('abort', abort, { once: true })
  90. })
  91. } catch (error: unknown) {
  92. await Promise.resolve()
  93. this.firstAgentDuringAbort = agents.requireInitiator()
  94. throw error
  95. }
  96. return
  97. }
  98. await Promise.resolve()
  99. this.laterAgent = agents.requireInitiator()
  100. yield* textResponse('reloaded')
  101. }
  102. }
  103. describe('AgentLoop initiator scope', () => {
  104. it('keeps overlapping driver continuations bound to their exact Agents', async () => {
  105. const ctx = new Context()
  106. const adapter = new OverlapAdapter(ctx)
  107. await ctx.plugin(LlmService)
  108. await ctx.plugin(SessionStore)
  109. await ctx.plugin(SystemPrompt)
  110. await ctx.plugin(ToolRegistry)
  111. await ctx.plugin(AgentRegistry)
  112. await ctx.plugin(AgentLoop, { agents: [] })
  113. ctx.llm.registerAdapter(['mock'], adapter)
  114. const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
  115. const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
  116. const idleA = waitForIdle(ctx, a)
  117. const idleB = waitForIdle(ctx, b)
  118. send(a, 'a')
  119. send(b, 'b')
  120. await Promise.all([idleA, idleB])
  121. expect(adapter.observations).toHaveLength(2)
  122. expect(adapter.observations).toEqual(expect.arrayContaining([
  123. { sessionId: a.session.id, before: a, after: a },
  124. { sessionId: b.session.id, before: b, after: b },
  125. ]))
  126. expect(ctx.agents.currentInitiator()).toBeUndefined()
  127. await ctx.fiber.dispose()
  128. })
  129. it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => {
  130. const adapter = new MockAdapter([
  131. toolCallResponse('spawn', 'spawn-child', {}),
  132. toolCallResponse('observe', 'observe-child', {}),
  133. textResponse('child done'),
  134. textResponse('parent done'),
  135. ])
  136. const { ctx } = await harness(adapter)
  137. let parentDuringSetup: Agent | undefined
  138. let explicitChild: Agent | undefined
  139. let childDuringDriver: Agent | undefined
  140. let parentWhileChildDriverActive: Agent | undefined
  141. let child: Agent | undefined
  142. ctx.tools.register(defineTool({
  143. name: 'spawn-child',
  144. description: 'create one child agent',
  145. parameters: {},
  146. execute: async (_args, exec) => {
  147. if (exec.agent === undefined) throw new Error('parent agent missing')
  148. const handle = await exec.agent.ctx.agents.create({
  149. sessionId: SessionId('child-session'),
  150. agentOptions: { provider: 'mock', model: 'mock' },
  151. setup: (agentCtx) => {
  152. parentDuringSetup = ctx.agents.requireInitiator()
  153. explicitChild = agentCtx.agent
  154. agentCtx.tools.register(defineTool({
  155. name: 'observe-child',
  156. description: 'observe child execution identity',
  157. parameters: {},
  158. execute: async () => {
  159. await Promise.resolve()
  160. childDuringDriver = ctx.agents.requireInitiator()
  161. return [{ type: 'text', text: 'observed' }]
  162. },
  163. }))
  164. },
  165. })
  166. child = handle.agent
  167. parentWhileChildDriverActive = ctx.agents.requireInitiator()
  168. send(handle.agent, 'run child')
  169. await handle.agent.whenIdle()
  170. await handle.dispose()
  171. return [{ type: 'text', text: 'child completed' }]
  172. },
  173. }))
  174. const parentHandle = await ctx.agents.create({
  175. sessionId: SessionId('parent-session'),
  176. agentOptions: { provider: 'mock', model: 'mock' },
  177. })
  178. const idle = waitForIdle(ctx, parentHandle.agent)
  179. send(parentHandle.agent, 'spawn')
  180. await idle
  181. expect(parentDuringSetup).toBe(parentHandle.agent)
  182. expect(explicitChild).toBe(child)
  183. expect(childDuringDriver).toBe(child)
  184. expect(parentWhileChildDriverActive).toBe(parentHandle.agent)
  185. expect(ctx.agents.currentInitiator()).toBeUndefined()
  186. await parentHandle.dispose()
  187. await ctx.fiber.dispose()
  188. })
  189. it('keeps agentless direct tools ambient-free and builds trusted transport headers internally', async () => {
  190. const adapter = new MockAdapter([
  191. toolCallResponse('capability', 'capability-request', { path: '/v1/capability' }),
  192. textResponse('done'),
  193. ])
  194. const { ctx } = await harness(adapter)
  195. const transport = new TestCapabilityTransport(ctx.agents)
  196. let directAmbient: Agent | undefined
  197. let captured: Agent | undefined
  198. ctx.tools.register(defineTool({
  199. name: 'agentless-probe',
  200. description: 'observe an agentless call',
  201. parameters: {},
  202. execute: async () => {
  203. await Promise.resolve()
  204. directAmbient = ctx.agents.currentInitiator()
  205. return [{ type: 'text', text: 'ok' }]
  206. },
  207. }))
  208. ctx.tools.register(defineTool({
  209. name: 'capability-request',
  210. description: 'call the test capability transport',
  211. parameters: { path: { type: 'string' } },
  212. execute: async (args) => {
  213. captured = ctx.agents.requireInitiator()
  214. const path = (args as { path: string }).path
  215. const headers = await transport.request(path)
  216. return [{ type: 'text', text: JSON.stringify(headers) }]
  217. },
  218. }))
  219. const direct = await ctx.tools.execute({
  220. callId: CallId('direct'),
  221. name: 'agentless-probe',
  222. arguments: {},
  223. })
  224. expect(direct.isError).toBe(false)
  225. expect(directAmbient).toBeUndefined()
  226. const handle = await ctx.agents.create({
  227. sessionId: SessionId('transport-session'),
  228. agentOptions: { provider: 'mock', model: 'mock' },
  229. })
  230. const idle = waitForIdle(ctx, handle.agent)
  231. send(handle.agent, 'call transport')
  232. await idle
  233. expect(transport.requests).toEqual([{
  234. path: '/v1/capability',
  235. headers: { 'X-Harness-Session-Id': 'transport-session' },
  236. }])
  237. const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request')
  238. expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i)
  239. const call = handle.agent.session.events.find(event => event.type === 'tool/call')
  240. expect(call?.type === 'tool/call' ? call.data.arguments : undefined)
  241. .toBe(JSON.stringify({ path: '/v1/capability' }))
  242. expect(captured).toBe(handle.agent)
  243. await handle.dispose()
  244. expect(captured?.status).toBe('disposed')
  245. expect(ctx.agents.currentInitiator()).toBeUndefined()
  246. await ctx.fiber.dispose()
  247. })
  248. it('drains the old driver before disabling ALS during agent-service restart', async () => {
  249. const adapter = new ReloadAdapter()
  250. const { ctx, agentsFiber, loopFiber } = await harness(adapter)
  251. const oldService = ctx.agents
  252. adapter.agents = oldService
  253. const oldHandle = await ctx.agents.create({
  254. sessionId: SessionId('before-restart-session'),
  255. agentOptions: { provider: 'mock', model: 'mock' },
  256. })
  257. const oldAgent = oldHandle.agent
  258. send(oldAgent, 'block')
  259. await adapter.firstStarted.promise
  260. await agentsFiber.restart()
  261. await loopFiber.await()
  262. expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id)
  263. expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session)
  264. expect(oldAgent.status).toBe('disposed')
  265. expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed')
  266. expect(ctx.agents).not.toBe(oldService)
  267. adapter.agents = ctx.agents
  268. const newHandle = await ctx.agents.create({
  269. sessionId: SessionId('after-restart-session'),
  270. agentOptions: { provider: 'mock', model: 'mock' },
  271. })
  272. const newAgent = newHandle.agent
  273. const idle = waitForIdle(ctx, newAgent)
  274. send(newAgent, 'continue')
  275. await idle
  276. expect(adapter.laterAgent?.id).toBe(newAgent.id)
  277. expect(adapter.laterAgent?.session).toBe(newAgent.session)
  278. await ctx.fiber.dispose()
  279. })
  280. it('keeps ALS readable while root disposal drains sibling AgentLoop fibers', async () => {
  281. const ctx = new Context()
  282. const adapter = new ReloadAdapter()
  283. await ctx.plugin(LlmService)
  284. await ctx.plugin(SessionStore)
  285. await ctx.plugin(SystemPrompt)
  286. await ctx.plugin(ToolRegistry)
  287. await ctx.plugin(AgentRegistry)
  288. await ctx.plugin(AgentLoop, { agents: [] })
  289. ctx.llm.registerAdapter(['mock'], adapter)
  290. const service = ctx.agents
  291. adapter.agents = service
  292. const handle = await ctx.agents.create({
  293. sessionId: SessionId('root-dispose-session'),
  294. agentOptions: { provider: 'mock', model: 'mock' },
  295. })
  296. const agent = handle.agent
  297. send(agent, 'block')
  298. await adapter.firstStarted.promise
  299. await ctx.fiber.dispose()
  300. expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id)
  301. expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session)
  302. expect(agent.status).toBe('disposed')
  303. expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
  304. })
  305. })