@pikku/core 0.12.56 → 0.12.58

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.
@@ -8,6 +8,28 @@ import type {
8
8
  GatewayOutboundMessage,
9
9
  PikkuGateway,
10
10
  } from './gateway.types.js'
11
+
12
+ /**
13
+ * Lazily resolve a gateway's adapter. Factories are invoked once with the
14
+ * singleton services and cached (promise-cached, so concurrent first
15
+ * requests share one construction).
16
+ */
17
+ const resolvedAdapters = new WeakMap<CoreGateway, Promise<GatewayAdapter>>()
18
+
19
+ export const resolveGatewayAdapter = (
20
+ config: CoreGateway,
21
+ services: CoreSingletonServices
22
+ ): Promise<GatewayAdapter> => {
23
+ let resolved = resolvedAdapters.get(config)
24
+ if (!resolved) {
25
+ resolved =
26
+ typeof config.adapter === 'function'
27
+ ? Promise.resolve(config.adapter(services))
28
+ : Promise.resolve(config.adapter)
29
+ resolvedAdapters.set(config, resolved)
30
+ }
31
+ return resolved
32
+ }
11
33
  import type {
12
34
  PikkuWire,
13
35
  PikkuRawWire,
@@ -94,8 +116,10 @@ const wireWebhookGateway = (config: CoreGateway): void => {
94
116
  } as any)
95
117
 
96
118
  // --- GET handler (webhook verification, e.g. WhatsApp challenge) ---------
119
+ // Factory adapters can't be probed for verifyWebhook until first resolve,
120
+ // so register the GET route unconditionally for them.
97
121
 
98
- if (adapter.verifyWebhook) {
122
+ if (typeof adapter === 'function' || adapter.verifyWebhook) {
99
123
  const verifyFuncId = `gateway__${name}__verify`
100
124
 
101
125
  funcMeta[verifyFuncId] = {
@@ -113,7 +137,7 @@ const wireWebhookGateway = (config: CoreGateway): void => {
113
137
 
114
138
  const verifyHandler = {
115
139
  auth: false,
116
- func: createWebhookVerifyHandler(adapter),
140
+ func: createWebhookVerifyHandler(config),
117
141
  }
118
142
 
119
143
  addFunction(verifyFuncId, verifyHandler as any)
@@ -145,7 +169,7 @@ const wireWebhookGateway = (config: CoreGateway): void => {
145
169
  * 6. Auto-send response via adapter if func returns outbound content
146
170
  */
147
171
  const createWebhookPostHandler = (config: CoreGateway) => {
148
- const { name, adapter, func: userFunc, middleware: userMiddleware } = config
172
+ const { name, func: userFunc, middleware: userMiddleware } = config
149
173
  const userFuncConfig = userFunc as {
150
174
  func: Function
151
175
  middleware?: CorePikkuMiddleware[]
@@ -156,6 +180,8 @@ const createWebhookPostHandler = (config: CoreGateway) => {
156
180
  data: unknown,
157
181
  wire: PikkuWire
158
182
  ) => {
183
+ const adapter = await resolveGatewayAdapter(config, services)
184
+
159
185
  // Check for POST-based webhook verification (e.g. Slack url_verification)
160
186
  if (adapter.verifyWebhook) {
161
187
  const verifyResult = await adapter.verifyWebhook(data, wire.http?.request)
@@ -207,12 +233,13 @@ const createWebhookPostHandler = (config: CoreGateway) => {
207
233
  * Creates the GET handler for webhook verification challenges.
208
234
  * Passes query parameters to the adapter's verifyWebhook method.
209
235
  */
210
- const createWebhookVerifyHandler = (adapter: GatewayAdapter) => {
236
+ const createWebhookVerifyHandler = (config: CoreGateway) => {
211
237
  return async (
212
- _services: CoreSingletonServices,
238
+ services: CoreSingletonServices,
213
239
  _data: unknown,
214
240
  wire: PikkuWire
215
241
  ) => {
242
+ const adapter = await resolveGatewayAdapter(config, services)
216
243
  if (!adapter.verifyWebhook) {
217
244
  return { error: 'Verification not supported' }
218
245
  }
@@ -232,7 +259,7 @@ const createWebhookVerifyHandler = (adapter: GatewayAdapter) => {
232
259
  // ---------------------------------------------------------------------------
233
260
 
234
261
  const wireWebsocketGateway = (config: CoreGateway): void => {
235
- const { name, route, adapter } = config
262
+ const { name, route } = config
236
263
  if (!route) {
237
264
  throw new Error(`WebSocket gateway '${name}' requires a route`)
238
265
  }
@@ -279,7 +306,8 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
279
306
  // Register onConnect
280
307
  addFunction(connectFuncId, {
281
308
  auth: false,
282
- func: async (_services: any, _data: unknown, wire: PikkuWire) => {
309
+ func: async (services: any, _data: unknown, wire: PikkuWire) => {
310
+ const adapter = await resolveGatewayAdapter(config, services)
283
311
  ;(wire as any).gateway = {
284
312
  gatewayName: name,
285
313
  senderId: '',
@@ -295,6 +323,7 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
295
323
  addFunction(messageFuncId, {
296
324
  auth: false,
297
325
  func: async (services: any, data: unknown, wire: PikkuWire) => {
326
+ const adapter = await resolveGatewayAdapter(config, services)
298
327
  const parsed = adapter.parse(data)
299
328
  if (!parsed) return
300
329
 
@@ -371,7 +400,6 @@ export const createListenerMessageHandler = (
371
400
  config: CoreGateway,
372
401
  singletonServices: CoreSingletonServices
373
402
  ): ((rawData: unknown) => Promise<void>) => {
374
- const { adapter } = config
375
403
  const userFuncConfig = config.func as {
376
404
  func: Function
377
405
  middleware?: CorePikkuMiddleware[]
@@ -379,6 +407,7 @@ export const createListenerMessageHandler = (
379
407
  const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
380
408
 
381
409
  return async (rawData: unknown): Promise<void> => {
410
+ const adapter = await resolveGatewayAdapter(config, singletonServices)
382
411
  const parsed = adapter.parse(rawData)
383
412
  if (!parsed) return
384
413
 
@@ -2,6 +2,7 @@ import type {
2
2
  CommonWireMeta,
3
3
  CorePikkuMiddleware,
4
4
  CorePikkuMiddlewareGroup,
5
+ CoreSingletonServices,
5
6
  } from '../../types/core.types.js'
6
7
  import type {
7
8
  CorePikkuFunctionConfig,
@@ -83,6 +84,19 @@ export interface GatewayAdapter {
83
84
  ): WebhookVerificationResult | Promise<WebhookVerificationResult>
84
85
  }
85
86
 
87
+ /**
88
+ * Factory that builds a GatewayAdapter from singleton services.
89
+ *
90
+ * Real platform adapters (WhatsApp Cloud API, Slack, …) need secrets or
91
+ * services that only exist after boot, while `wireGateway` runs at module
92
+ * load. Pass a factory instead of an instance and it is resolved lazily on
93
+ * the first inbound request (webhook/websocket) or on gateway start
94
+ * (listener), then cached for the lifetime of the gateway.
95
+ */
96
+ export type GatewayAdapterFactory = (
97
+ services: CoreSingletonServices
98
+ ) => GatewayAdapter | Promise<GatewayAdapter>
99
+
86
100
  /**
87
101
  * The gateway wire object available on wire.gateway inside handler functions and middleware
88
102
  */
@@ -122,8 +136,9 @@ export type CoreGateway<
122
136
  /** HTTP route for webhook/websocket types */
123
137
  route?: string
124
138
  platform?: string
125
- /** The gateway adapter (parse inbound, send outbound) */
126
- adapter: GatewayAdapter
139
+ /** The gateway adapter (parse inbound, send outbound), or a factory
140
+ * resolved lazily from singleton services (for adapters needing secrets) */
141
+ adapter: GatewayAdapter | GatewayAdapterFactory
127
142
  /** The handler function that processes parsed messages */
128
143
  func: PikkuFunctionConfig
129
144
  /** Optional middleware chain (e.g., auth) */
@@ -1,6 +1,11 @@
1
- export { wireGateway, createListenerMessageHandler } from './gateway-runner.js'
1
+ export {
2
+ wireGateway,
3
+ createListenerMessageHandler,
4
+ resolveGatewayAdapter,
5
+ } from './gateway-runner.js'
2
6
  export type {
3
7
  GatewayAdapter,
8
+ GatewayAdapterFactory,
4
9
  GatewayAttachment,
5
10
  GatewayInboundMessage,
6
11
  GatewayOutboundMessage,
@@ -677,6 +677,140 @@ describe('graph-runner bugs', () => {
677
677
  delete metaState['testInlineMetaGraph']
678
678
  })
679
679
 
680
+ test('executeGraphStep dispatches an agent-name node via the agent-run path and returns its result', async () => {
681
+ const ws = new InMemoryWorkflowService()
682
+
683
+ const agentsMeta = pikkuState(null, 'agent', 'agentsMeta')
684
+ agentsMeta['summarize'] = {
685
+ name: 'summarize',
686
+ inputSchema: null,
687
+ outputSchema: null,
688
+ workingMemorySchema: null,
689
+ } as any
690
+
691
+ const metaState = pikkuState(null, 'workflows', 'meta')
692
+ metaState['testAgentNode'] = {
693
+ name: 'testAgentNode',
694
+ pikkuFuncId: 'testAgentNode',
695
+ source: 'graph',
696
+ entryNodeIds: ['a'],
697
+ graphHash: 'agent-node-hash',
698
+ nodes: {
699
+ a: { nodeId: 'a', rpcName: 'summarize' },
700
+ },
701
+ }
702
+
703
+ const runId = await ws.createRun(
704
+ 'testAgentNode',
705
+ {},
706
+ false,
707
+ 'agent-node-hash',
708
+ { type: 'test' }
709
+ )
710
+ const agentInput = { message: 'hi', threadId: 't', resourceId: 'r' }
711
+ const step = await ws.insertStepState(runId, 'a', 'summarize', agentInput)
712
+
713
+ let agentRunCall: { name: string; input: any } | null = null
714
+ const rpcService = {
715
+ rpcWithWire: async () => {
716
+ throw new Error('should not invoke rpcWithWire for an agent node')
717
+ },
718
+ agent: {
719
+ run: async (name: string, input: any) => {
720
+ agentRunCall = { name, input }
721
+ return {
722
+ runId: 'agent-run-1',
723
+ result: { summary: 'done' },
724
+ usage: { inputTokens: 1, outputTokens: 2 },
725
+ }
726
+ },
727
+ },
728
+ }
729
+
730
+ const result = await executeGraphStep(
731
+ ws,
732
+ rpcService,
733
+ runId,
734
+ step.stepId,
735
+ 'a',
736
+ 'summarize',
737
+ agentInput,
738
+ 'testAgentNode'
739
+ )
740
+
741
+ assert.deepEqual(result, { summary: 'done' })
742
+ assert.deepEqual(agentRunCall, { name: 'summarize', input: agentInput })
743
+
744
+ delete metaState['testAgentNode']
745
+ delete agentsMeta['summarize']
746
+ })
747
+
748
+ test('inline graph dispatches an agent node and a downstream node consumes its result', async () => {
749
+ const ws = new InMemoryWorkflowService()
750
+
751
+ const agentsMeta = pikkuState(null, 'agent', 'agentsMeta')
752
+ agentsMeta['classifier'] = {
753
+ name: 'classifier',
754
+ inputSchema: null,
755
+ outputSchema: null,
756
+ workingMemorySchema: null,
757
+ } as any
758
+
759
+ let consumed: any = null
760
+ const rpcService = {
761
+ rpcWithWire: async (rpcName: string, data: any) => {
762
+ if (rpcName === 'consume') {
763
+ consumed = data
764
+ return { ok: true }
765
+ }
766
+ return {}
767
+ },
768
+ agent: {
769
+ run: async () => ({
770
+ runId: 'r',
771
+ result: { category: 'urgent' },
772
+ usage: { inputTokens: 0, outputTokens: 0 },
773
+ }),
774
+ },
775
+ }
776
+
777
+ const metaState = pikkuState(null, 'workflows', 'meta')
778
+ metaState['testInlineAgentNode'] = {
779
+ name: 'testInlineAgentNode',
780
+ pikkuFuncId: 'testInlineAgentNode',
781
+ source: 'graph',
782
+ entryNodeIds: ['agentNode'],
783
+ graphHash: 'inline-agent-hash',
784
+ nodes: {
785
+ agentNode: {
786
+ nodeId: 'agentNode',
787
+ rpcName: 'classifier',
788
+ next: 'consume',
789
+ },
790
+ consume: {
791
+ nodeId: 'consume',
792
+ rpcName: 'consume',
793
+ input: { category: { $ref: 'agentNode', path: 'category' } },
794
+ },
795
+ },
796
+ }
797
+
798
+ const { runId } = await runWorkflowGraph(
799
+ ws,
800
+ 'testInlineAgentNode',
801
+ { message: 'x', threadId: 't', resourceId: 'r' },
802
+ rpcService,
803
+ true
804
+ )
805
+
806
+ const run = await ws.getRun(runId)
807
+ assert.equal(run?.status, 'completed')
808
+ assert.deepEqual(consumed, { category: 'urgent' })
809
+
810
+ delete metaState['testInlineAgentNode']
811
+ delete agentsMeta['classifier']
812
+ })
813
+
680
814
  test('queueGraphNode forwards node retries to queue attempts and backoff', async () => {
681
815
  const ws = new InMemoryWorkflowService()
682
816
  const enqueued: Array<{ queueName: string; data: any; options: any }> = []
@@ -621,6 +621,9 @@ export async function executeGraphStep(
621
621
  let result: any
622
622
 
623
623
  const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
624
+ const agentMeta = subWorkflowMeta
625
+ ? undefined
626
+ : pikkuState(null, 'agent', 'agentsMeta')[rpcName]
624
627
  if (subWorkflowMeta) {
625
628
  const childWire: WorkflowRunWire = {
626
629
  type: 'workflow',
@@ -650,6 +653,9 @@ export async function executeGraphStep(
650
653
  } else {
651
654
  throw new ChildWorkflowStartedException(runId, stepId, childRunId)
652
655
  }
656
+ } else if (agentMeta) {
657
+ const agentRun = await rpcService.agent.run(rpcName, data)
658
+ result = agentRun.result
653
659
  } else {
654
660
  result = await invokeGraphNodeRpc(
655
661
  workflowService,
@@ -760,6 +766,9 @@ async function executeGraphNodeInline(
760
766
  let result: any
761
767
 
762
768
  const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
769
+ const agentMeta = subWorkflowMeta
770
+ ? undefined
771
+ : pikkuState(null, 'agent', 'agentsMeta')[rpcName]
763
772
  if (subWorkflowMeta) {
764
773
  const childWire: WorkflowRunWire = {
765
774
  type: 'workflow',
@@ -783,6 +792,9 @@ async function executeGraphNodeInline(
783
792
  throw new Error('Sub-workflow was cancelled')
784
793
  }
785
794
  result = childRun?.output
795
+ } else if (agentMeta) {
796
+ const agentRun = await rpcService.agent.run(rpcName, input)
797
+ result = agentRun.result
786
798
  } else {
787
799
  result = await invokeGraphNodeRpc(
788
800
  workflowService,