agent-initiator.spec.ts 15 KB

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