@roj-ai/sdk 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,9 @@ import { MockLLMProvider } from '~/core/llm/mock.js'
4
4
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
5
5
  import { ToolCallId } from '~/core/tools/schema.js'
6
6
  import { toolEvents } from '~/core/tools/state.js'
7
+ import { Ok } from '~/lib/utils/result.js'
7
8
  import { createTestPreset, TestHarness } from '~/testing/index.js'
9
+ import z from 'zod/v4'
8
10
 
9
11
  describe('core: plugin hooks', () => {
10
12
  // =========================================================================
@@ -373,6 +375,80 @@ describe('core: plugin hooks', () => {
373
375
 
374
376
  await harness.shutdown()
375
377
  })
378
+
379
+ describe('beforeMethod', () => {
380
+ it('returning { action: "deny" } → method call rejected and handler not run', async () => {
381
+ let handlerCalls = 0
382
+ const seenMethods: string[] = []
383
+
384
+ const gatedPlugin = definePlugin('gated')
385
+ .method('blocked', {
386
+ input: z.object({}),
387
+ output: z.object({ ok: z.boolean() }),
388
+ handler: async () => {
389
+ handlerCalls++
390
+ return Ok({ ok: true })
391
+ },
392
+ })
393
+ .sessionHook('beforeMethod', async (ctx) => {
394
+ seenMethods.push(ctx.method)
395
+ if (ctx.method === 'gated.blocked') {
396
+ return { action: 'deny', reason: 'blocked by test' }
397
+ }
398
+ return null
399
+ })
400
+ .build()
401
+
402
+ const harness = new TestHarness({
403
+ presets: [createTestPreset()],
404
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
405
+ systemPlugins: [gatedPlugin],
406
+ })
407
+
408
+ const session = await harness.createSession('test')
409
+ const result = await session.callPluginMethod('gated.blocked', {})
410
+
411
+ expect(result.ok).toBe(false)
412
+ if (!result.ok) {
413
+ expect(result.error.type).toBe('method_denied')
414
+ expect(result.error.message).toBe('blocked by test')
415
+ }
416
+ expect(handlerCalls).toBe(0)
417
+ expect(seenMethods).toContain('gated.blocked')
418
+
419
+ await harness.shutdown()
420
+ })
421
+
422
+ it('returning null → method call proceeds to the handler', async () => {
423
+ let handlerCalls = 0
424
+
425
+ const gatedPlugin = definePlugin('gated')
426
+ .method('allowed', {
427
+ input: z.object({}),
428
+ output: z.object({ ok: z.boolean() }),
429
+ handler: async () => {
430
+ handlerCalls++
431
+ return Ok({ ok: true })
432
+ },
433
+ })
434
+ .sessionHook('beforeMethod', async () => null)
435
+ .build()
436
+
437
+ const harness = new TestHarness({
438
+ presets: [createTestPreset()],
439
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
440
+ systemPlugins: [gatedPlugin],
441
+ })
442
+
443
+ const session = await harness.createSession('test')
444
+ const result = await session.callPluginMethod('gated.allowed', {})
445
+
446
+ expect(result.ok).toBe(true)
447
+ expect(handlerCalls).toBe(1)
448
+
449
+ await harness.shutdown()
450
+ })
451
+ })
376
452
  })
377
453
 
378
454
  // =========================================================================
@@ -425,4 +501,68 @@ describe('core: plugin hooks', () => {
425
501
  await harness.shutdown()
426
502
  })
427
503
  })
504
+
505
+ // =========================================================================
506
+ // beforeDequeue gate
507
+ // =========================================================================
508
+
509
+ describe('beforeDequeue', () => {
510
+ it('holding a source → messages do not wake the agent, and are re-delivered (not dropped) once released', async () => {
511
+ let holding = true
512
+
513
+ const gatePlugin = definePlugin('budget-gate')
514
+ .beforeDequeue((ctx) => {
515
+ if (holding && ctx.sourcePlugin === 'user-chat') {
516
+ return { action: 'hold' }
517
+ }
518
+ return null
519
+ })
520
+ .build()
521
+
522
+ const harness = new TestHarness({
523
+ presets: [createTestPreset()],
524
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
525
+ systemPlugins: [gatePlugin],
526
+ })
527
+
528
+ const session = await harness.createSession('test')
529
+
530
+ // Held: the user message is queued but must not trigger an inference.
531
+ await session.sendMessage('first')
532
+ await new Promise((r) => setTimeout(r, 50))
533
+ expect(harness.llmProvider.getLastRequest()).toBeUndefined()
534
+
535
+ // Released: a new message wakes the agent; the held 'first' must be
536
+ // re-delivered alongside 'second' (held, not dropped).
537
+ holding = false
538
+ await session.sendAndWaitForIdle('second')
539
+
540
+ const req = harness.llmProvider.getLastRequest()
541
+ expect(req).toBeDefined()
542
+ const serialized = JSON.stringify(req!.messages)
543
+ expect(serialized).toContain('first')
544
+ expect(serialized).toContain('second')
545
+
546
+ await harness.shutdown()
547
+ })
548
+
549
+ it('returning null → messages flow through normally', async () => {
550
+ const gatePlugin = definePlugin('budget-gate')
551
+ .beforeDequeue(() => null)
552
+ .build()
553
+
554
+ const harness = new TestHarness({
555
+ presets: [createTestPreset()],
556
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
557
+ systemPlugins: [gatePlugin],
558
+ })
559
+
560
+ const session = await harness.createSession('test')
561
+ await session.sendAndWaitForIdle('hello')
562
+
563
+ expect(harness.llmProvider.getLastRequest()).toBeDefined()
564
+
565
+ await harness.shutdown()
566
+ })
567
+ })
428
568
  })
@@ -41,6 +41,26 @@ export type OnErrorResult =
41
41
 
42
42
  export type OnPauseResult = null
43
43
 
44
+ /**
45
+ * Result of a `beforeMethod` session hook — lets a plugin veto a plugin-method
46
+ * call before its handler runs (e.g. a budget guard blocking new user messages
47
+ * while allowing internal agent-to-agent calls).
48
+ */
49
+ export type BeforeMethodResult =
50
+ | null
51
+ | { action: 'deny'; reason: string }
52
+
53
+ /**
54
+ * Result of a `beforeDequeue` hook — lets a plugin HOLD (not deliver) the
55
+ * pending messages of a given source plugin for the current cycle. Held
56
+ * messages stay queued (not consumed) and are re-delivered on a later cycle —
57
+ * e.g. a budget guard holding new user/upload input while letting inter-agent
58
+ * mailbox traffic through so running agents can finish.
59
+ */
60
+ export type BeforeDequeueResult =
61
+ | null
62
+ | { action: 'hold' }
63
+
44
64
  export type HandlerName =
45
65
  | 'onStart'
46
66
  | 'beforeInference'
@@ -26,7 +26,9 @@ import type { ToolCall } from '../tools/schema.js'
26
26
  import type {
27
27
  AfterInferenceResult,
28
28
  AfterToolCallResult,
29
+ BeforeDequeueResult,
29
30
  BeforeInferenceResult,
31
+ BeforeMethodResult,
30
32
  BeforeToolCallResult,
31
33
  OnCompleteResult,
32
34
  OnErrorResult,
@@ -237,6 +239,7 @@ type HookMap<TCtx> = {
237
239
  type SessionHookMap<TCtx> = {
238
240
  onSessionReady: (ctx: TCtx) => Promise<void>
239
241
  onSessionClose: (ctx: TCtx) => Promise<void>
242
+ beforeMethod: (ctx: TCtx & { method: string; input: unknown; agentId?: AgentId }) => Promise<BeforeMethodResult>
240
243
  }
241
244
 
242
245
  // ============================================================================
@@ -311,6 +314,7 @@ type ErasedAgentHookMap = {
311
314
  type ErasedSessionHookMap = {
312
315
  onSessionReady: (ctx: BaseSessionHookContext) => Promise<void>
313
316
  onSessionClose: (ctx: BaseSessionHookContext) => Promise<void>
317
+ beforeMethod: (ctx: BaseSessionHookContext & { method: string; input: unknown; agentId?: AgentId }) => Promise<BeforeMethodResult>
314
318
  }
315
319
 
316
320
  // ============================================================================
@@ -351,6 +355,8 @@ export interface ConfiguredPlugin {
351
355
  getPendingMessages: (ctx: BasePluginHookContext) => PluginPendingMessages | null
352
356
  markConsumed: (ctx: BasePluginHookContext, token: unknown) => Promise<void>
353
357
  }
358
+ /** Gate run before delivering another plugin's dequeued messages — return 'hold' to keep them queued this cycle. */
359
+ beforeDequeue?: (ctx: BasePluginHookContext & { sourcePlugin: string }) => BeforeDequeueResult
354
360
  slice?: StateSlice
355
361
  isEnabled?: (ctx: { pluginConfig: unknown; pluginAgentConfig: unknown; agentConfig: AgentConfig }) => boolean
356
362
  isSessionEnabled?: (ctx: { pluginConfig: unknown }) => boolean
@@ -443,6 +449,7 @@ interface BuilderConfig {
443
449
  getPendingMessages: (ctx: BasePluginHookContext) => PluginPendingMessages | null
444
450
  markConsumed: (ctx: BasePluginHookContext, token: unknown) => Promise<void>
445
451
  } | undefined
452
+ beforeDequeueFn: ((ctx: BasePluginHookContext & { sourcePlugin: string }) => BeforeDequeueResult) | undefined
446
453
  }
447
454
 
448
455
  // ============================================================================
@@ -486,6 +493,7 @@ export class PluginBuilder<
486
493
  sessionHooks: {},
487
494
  isEnabledFn: undefined,
488
495
  dequeueHook: undefined,
496
+ beforeDequeueFn: undefined,
489
497
  }
490
498
  }
491
499
 
@@ -748,6 +756,20 @@ export class PluginBuilder<
748
756
  return this
749
757
  }
750
758
 
759
+ /**
760
+ * Gate the delivery of OTHER plugins' dequeued messages. Runs once per source
761
+ * plugin that has pending messages, before they wake the agent; return
762
+ * `{ action: 'hold' }` to keep that source's messages queued this cycle
763
+ * (re-delivered later). `ctx.sourcePlugin` is the name of the plugin whose
764
+ * messages are being considered.
765
+ */
766
+ beforeDequeue(
767
+ fn: (ctx: PluginHookContext<TConfig, TMethods, TAgentConfig, TContext, TState, TNotifications, TDeps> & { sourcePlugin: string }) => BeforeDequeueResult,
768
+ ): this {
769
+ this._cfg.beforeDequeueFn = fn as BuilderConfig['beforeDequeueFn']
770
+ return this
771
+ }
772
+
751
773
  // --- Build ---
752
774
 
753
775
  build(): PluginDefinition<TName, TConfig, TAgentConfig, TManagerMethods, TMethods> {
@@ -811,11 +833,13 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
811
833
  ) as Partial<ErasedAgentHookMap>
812
834
  : undefined
813
835
 
814
- const sessionHookEntries = Object.entries(cfg.sessionHooks)
836
+ const sessionHookEntries = Object.entries(cfg.sessionHooks) as [string, (ctx: BaseSessionHookContext) => Promise<unknown>][]
815
837
  const sessionHooks: Partial<ErasedSessionHookMap> | undefined = sessionHookEntries.length > 0
816
838
  ? Object.fromEntries(
817
839
  sessionHookEntries.map(([name, fn]) => [
818
840
  name,
841
+ // Session.ts provides the full context with extra fields (method, input,
842
+ // agentId for beforeMethod) — this wrapper just injects pluginConfig.
819
843
  (ctx: BaseSessionHookContext) => fn({ ...ctx, pluginConfig }),
820
844
  ]),
821
845
  ) as Partial<ErasedSessionHookMap>
@@ -872,6 +896,11 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
872
896
  }
873
897
  : undefined
874
898
 
899
+ const beforeDequeueFn = cfg.beforeDequeueFn
900
+ const wrappedBeforeDequeue = beforeDequeueFn
901
+ ? (ctx: BasePluginHookContext & { sourcePlugin: string }) => beforeDequeueFn({ ...ctx, pluginConfig })
902
+ : undefined
903
+
875
904
  // Wrap isEnabled to pass pluginConfig
876
905
  const wrappedIsEnabled = cfg.isEnabledFn
877
906
  ? (ctx: { pluginConfig: unknown; pluginAgentConfig: unknown; agentConfig: AgentConfig }) => {
@@ -916,6 +945,7 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
916
945
  getStatus: wrappedStatus,
917
946
  getSystemPrompt: wrappedSystemPrompt,
918
947
  dequeue: wrappedDequeue,
948
+ beforeDequeue: wrappedBeforeDequeue,
919
949
  slice,
920
950
  isEnabled: wrappedIsEnabled,
921
951
  isSessionEnabled: wrappedIsSessionEnabled,
@@ -12,7 +12,7 @@ import { COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE } from '~/core/agents/agent-roles.
12
12
  import { AgentId, generateAgentId } from '~/core/agents/schema.js'
13
13
  import type { AgentState } from '~/core/agents/state.js'
14
14
  import { agentEvents, getChildren } from '~/core/agents/state.js'
15
- import { AgentErrors, type DomainError, SessionErrors, ValidationErrors } from '~/core/errors.js'
15
+ import { AgentErrors, type DomainError, MethodErrors, SessionErrors, ValidationErrors } from '~/core/errors.js'
16
16
  import { withSessionId } from '~/core/events/test-helpers.js'
17
17
  import type { DomainEvent } from '~/core/events/types.js'
18
18
  import type { LLMLogger } from '~/core/llm/logger.js'
@@ -478,6 +478,29 @@ export class Session {
478
478
  return Err(ValidationErrors.invalid(`Invalid input for ${method}: ${parsed.error.message}`))
479
479
  }
480
480
 
481
+ // beforeMethod gate — any plugin may veto this call before the handler runs
482
+ // (e.g. a budget guard blocking new user input). The hook receives `caller`,
483
+ // so a gate can allow internal AGENT_CALLER traffic (agent-to-agent) while
484
+ // denying external/user-originated calls. First deny wins.
485
+ for (const gatePlugin of this.plugins) {
486
+ if (!gatePlugin.sessionHooks?.beforeMethod) continue
487
+ try {
488
+ const gateCtx = {
489
+ ...this.buildSessionHookContext(gatePlugin),
490
+ caller: caller ?? DEFAULT_CALLER,
491
+ method,
492
+ input: parsed.data,
493
+ agentId,
494
+ }
495
+ const gate = await gatePlugin.sessionHooks.beforeMethod(gateCtx)
496
+ if (gate?.action === 'deny') {
497
+ return Err(MethodErrors.denied(gate.reason))
498
+ }
499
+ } catch (err) {
500
+ this.logger.error(`Plugin '${gatePlugin.name}' beforeMethod hook failed`, err instanceof Error ? err : undefined, { method })
501
+ }
502
+ }
503
+
481
504
  // Build MethodHandlerContext with plugin state, context, scheduleAgent, notify, and deps
482
505
  const sessionContext = this.buildSessionContext()
483
506
  const pluginState = plugin.slice ? plugin.slice.select(this.store.getState()) : undefined