@pikku/core 0.12.13 → 0.12.14

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/errors/errors.d.ts +14 -0
  3. package/dist/errors/errors.js +21 -0
  4. package/dist/function/function-runner.d.ts +1 -1
  5. package/dist/function/function-runner.js +8 -4
  6. package/dist/pikku-state.js +1 -0
  7. package/dist/services/credential-service.d.ts +11 -0
  8. package/dist/services/credential-wire-service.d.ts +14 -3
  9. package/dist/services/credential-wire-service.js +49 -6
  10. package/dist/services/local-credential-service.d.ts +2 -0
  11. package/dist/services/local-credential-service.js +20 -0
  12. package/dist/services/pikku-user-id.d.ts +3 -0
  13. package/dist/services/pikku-user-id.js +16 -0
  14. package/dist/services/typed-credential-service.d.ts +2 -0
  15. package/dist/services/typed-credential-service.js +6 -0
  16. package/dist/types/core.types.d.ts +6 -2
  17. package/dist/types/state.types.d.ts +7 -0
  18. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +21 -0
  19. package/dist/wirings/ai-agent/ai-agent-prepare.js +48 -0
  20. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  21. package/dist/wirings/ai-agent/ai-agent-stream.d.ts +2 -1
  22. package/dist/wirings/ai-agent/ai-agent-stream.js +60 -3
  23. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -1
  24. package/dist/wirings/ai-agent/index.d.ts +1 -1
  25. package/dist/wirings/ai-agent/index.js +1 -1
  26. package/dist/wirings/cli/cli-runner.js +1 -1
  27. package/dist/wirings/http/http-runner.js +0 -4
  28. package/dist/wirings/queue/queue-runner.js +1 -0
  29. package/dist/wirings/queue/queue.types.d.ts +6 -0
  30. package/dist/wirings/rpc/rpc-runner.js +1 -0
  31. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +2 -0
  32. package/dist/wirings/workflow/pikku-workflow-service.js +2 -0
  33. package/dist/wirings/workflow/workflow.types.d.ts +2 -0
  34. package/package.json +1 -1
  35. package/src/errors/errors.ts +32 -0
  36. package/src/function/function-runner.ts +19 -9
  37. package/src/pikku-state.ts +1 -0
  38. package/src/services/credential-service.ts +13 -0
  39. package/src/services/credential-wire-service.test.ts +174 -0
  40. package/src/services/credential-wire-service.ts +52 -8
  41. package/src/services/local-credential-service.ts +22 -0
  42. package/src/services/pikku-user-id.test.ts +62 -0
  43. package/src/services/pikku-user-id.ts +17 -0
  44. package/src/services/typed-credential-service.ts +8 -0
  45. package/src/types/core.types.ts +8 -2
  46. package/src/types/state.types.ts +5 -0
  47. package/src/wirings/ai-agent/ai-agent-prepare.ts +71 -1
  48. package/src/wirings/ai-agent/ai-agent-runner.ts +6 -2
  49. package/src/wirings/ai-agent/ai-agent-stream.ts +107 -3
  50. package/src/wirings/ai-agent/ai-agent.types.ts +22 -1
  51. package/src/wirings/ai-agent/index.ts +1 -0
  52. package/src/wirings/channel/channel-handler.ts +0 -1
  53. package/src/wirings/cli/cli-runner.ts +1 -1
  54. package/src/wirings/http/http-runner.ts +0 -7
  55. package/src/wirings/mcp/mcp-runner.ts +0 -1
  56. package/src/wirings/queue/queue-runner.ts +1 -1
  57. package/src/wirings/queue/queue.types.ts +6 -0
  58. package/src/wirings/rpc/rpc-runner.ts +9 -3
  59. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +2 -0
  60. package/src/wirings/workflow/pikku-workflow-service.ts +2 -0
  61. package/src/wirings/workflow/workflow.types.ts +2 -0
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -70,6 +70,75 @@ export class ToolApprovalRequired extends PikkuError {
70
70
  }
71
71
  }
72
72
 
73
+ export class ToolCredentialRequired extends PikkuError {
74
+ public readonly toolCallId: string
75
+ public readonly toolName: string
76
+ public readonly args: unknown
77
+ public readonly credentialName: string
78
+ public readonly credentialType: 'oauth2' | 'apikey'
79
+ public readonly connectUrl?: string
80
+
81
+ constructor(
82
+ toolCallId: string,
83
+ toolName: string,
84
+ args: unknown,
85
+ credentialName: string,
86
+ credentialType: 'oauth2' | 'apikey',
87
+ connectUrl?: string
88
+ ) {
89
+ super(`Tool '${toolName}' requires credential '${credentialName}'`)
90
+ this.toolCallId = toolCallId
91
+ this.toolName = toolName
92
+ this.args = args
93
+ this.credentialName = credentialName
94
+ this.credentialType = credentialType
95
+ this.connectUrl = connectUrl
96
+ }
97
+ }
98
+
99
+ export interface AddonCredentialRequirement {
100
+ credentialName: string
101
+ displayName: string
102
+ addonNamespace: string
103
+ type: 'wire'
104
+ oauth2: boolean
105
+ }
106
+
107
+ /**
108
+ * Given a list of tool names (e.g. ["oauth-api:getProfile"]),
109
+ * returns the wire OAuth credentials required by their addons.
110
+ */
111
+ export function getAddonCredentialRequirements(
112
+ toolNames: string[]
113
+ ): AddonCredentialRequirement[] {
114
+ const requirements = new Map<string, AddonCredentialRequirement>()
115
+
116
+ for (const toolName of toolNames) {
117
+ if (!toolName.includes(':')) continue
118
+ const resolved = resolveNamespace(toolName)
119
+ if (!resolved) continue
120
+
121
+ const credsMeta = pikkuState(resolved.package, 'package', 'credentialsMeta')
122
+ if (!credsMeta) continue
123
+
124
+ for (const [name, meta] of Object.entries(
125
+ credsMeta as Record<string, any>
126
+ )) {
127
+ if (meta.type === 'wire' && meta.oauth2 && !requirements.has(name)) {
128
+ requirements.set(name, {
129
+ credentialName: name,
130
+ displayName: meta.displayName ?? name,
131
+ addonNamespace: toolName.split(':')[0],
132
+ type: 'wire',
133
+ oauth2: true,
134
+ })
135
+ }
136
+ }
137
+ }
138
+
139
+ return [...requirements.values()]
140
+ }
141
+
73
142
  export type StreamContext = {
74
143
  channel: AIStreamChannel
75
144
  options?: StreamAIAgentOptions
@@ -478,8 +547,9 @@ export async function buildToolDefs(
478
547
  let execError: unknown
479
548
  try {
480
549
  result = await originalExecute(args)
481
- } catch (err) {
550
+ } catch (err: any) {
482
551
  execError = err
552
+ if (err?.payload?.error === 'missing_credential') throw err
483
553
  result = err instanceof Error ? err.message : String(err)
484
554
  }
485
555
  const durationMs = Date.now() - startTime
@@ -430,8 +430,12 @@ export async function resumeAIAgentSync(
430
430
  typeof toolResult === 'string'
431
431
  ? toolResult
432
432
  : JSON.stringify(toolResult)
433
- } catch (err) {
434
- resultStr = `Error: ${err instanceof Error ? err.message : String(err)}`
433
+ } catch (err: any) {
434
+ if (err?.payload?.error === 'missing_credential') {
435
+ resultStr = JSON.stringify(err.payload)
436
+ } else {
437
+ resultStr = `Error: ${err instanceof Error ? err.message : String(err)}`
438
+ }
435
439
  }
436
440
  } else {
437
441
  continue
@@ -36,6 +36,7 @@ import {
36
36
  buildToolDefs,
37
37
  createScopedChannel,
38
38
  ToolApprovalRequired,
39
+ ToolCredentialRequired,
39
40
  type RunAIAgentParams,
40
41
  type StreamAIAgentOptions,
41
42
  type StreamContext,
@@ -236,6 +237,7 @@ type StepLoopParams = {
236
237
  type StepLoopResult =
237
238
  | { outcome: 'done' }
238
239
  | { outcome: 'approval'; approvals: ToolApprovalRequired[] }
240
+ | { outcome: 'credential'; credentialRequests: ToolCredentialRequired[] }
239
241
 
240
242
  async function runStreamStepLoop(
241
243
  params: StepLoopParams
@@ -310,6 +312,13 @@ async function runStreamStepLoop(
310
312
  return { outcome: 'approval', approvals: approvalsNeeded }
311
313
  }
312
314
 
315
+ const credentialRequests = checkForCredentialRequests(stepResult, runId)
316
+ if (credentialRequests.length > 0) {
317
+ // Append step messages so the tool result is preserved for resume
318
+ appendStepMessages(runnerParams, stepResult)
319
+ return { outcome: 'credential', credentialRequests }
320
+ }
321
+
313
322
  appendStepMessages(runnerParams, stepResult)
314
323
  }
315
324
 
@@ -386,6 +395,40 @@ export function checkForApprovals(
386
395
  return approvals
387
396
  }
388
397
 
398
+ export function checkForCredentialRequests(
399
+ stepResult: AIAgentStepResult,
400
+ runId: string
401
+ ): ToolCredentialRequired[] {
402
+ const requests: ToolCredentialRequired[] = []
403
+ for (const tr of stepResult.toolResults) {
404
+ if (
405
+ tr.result &&
406
+ typeof tr.result === 'object' &&
407
+ '__credentialRequired' in (tr.result as object)
408
+ ) {
409
+ const r = tr.result as {
410
+ credentialName: string
411
+ credentialType: 'oauth2' | 'apikey'
412
+ connectUrl?: string
413
+ }
414
+ const tc = stepResult.toolCalls.find(
415
+ (t) => t.toolCallId === tr.toolCallId
416
+ )
417
+ requests.push(
418
+ new ToolCredentialRequired(
419
+ tr.toolCallId,
420
+ tc?.toolName ?? 'unknown',
421
+ tc?.args ?? {},
422
+ r.credentialName,
423
+ r.credentialType,
424
+ r.connectUrl
425
+ )
426
+ )
427
+ }
428
+ }
429
+ return requests
430
+ }
431
+
389
432
  export function appendStepMessages(
390
433
  runnerParams: AIAgentRunnerParams,
391
434
  stepResult: AIAgentStepResult
@@ -472,6 +515,40 @@ function handleApprovals(
472
515
  })()
473
516
  }
474
517
 
518
+ function handleCredentialRequests(
519
+ requests: ToolCredentialRequired[],
520
+ runId: string,
521
+ channel: AIStreamChannel,
522
+ aiRunState: AIRunStateService,
523
+ persistingChannel: PersistingChannel
524
+ ): Promise<void> {
525
+ return (async () => {
526
+ await persistingChannel.flush()
527
+
528
+ const pendingApprovals = requests.map((req) => ({
529
+ type: 'credential-request' as const,
530
+ toolCallId: req.toolCallId,
531
+ toolName: req.toolName,
532
+ args: req.args,
533
+ credentialName: req.credentialName,
534
+ credentialType: req.credentialType,
535
+ connectUrl: req.connectUrl,
536
+ }))
537
+
538
+ await aiRunState.updateRun(runId, {
539
+ status: 'suspended',
540
+ suspendReason: 'credential',
541
+ pendingApprovals,
542
+ })
543
+
544
+ // Don't send credential-request SSE events — the tool result with
545
+ // __credentialRequired was already streamed. The frontend detects it
546
+ // from the tool result and shows Connect/Ignore buttons.
547
+ channel.send({ type: 'done' })
548
+ channel.close()
549
+ })()
550
+ }
551
+
475
552
  export async function streamAIAgent(
476
553
  agentName: string,
477
554
  input: {
@@ -665,6 +742,17 @@ export async function streamAIAgent(
665
742
  return persistingChannel.fullText
666
743
  }
667
744
 
745
+ if (loopResult.outcome === 'credential') {
746
+ await handleCredentialRequests(
747
+ loopResult.credentialRequests,
748
+ runId,
749
+ channel,
750
+ aiRunState,
751
+ persistingChannel
752
+ )
753
+ return persistingChannel.fullText
754
+ }
755
+
668
756
  await postStreamCleanup(
669
757
  persistingChannel,
670
758
  aiMiddlewares,
@@ -769,7 +857,8 @@ export async function resumeAIAgent(
769
857
  {
770
858
  id: input.toolCallId,
771
859
  name:
772
- pending.type === 'tool-call'
860
+ pending.type === 'tool-call' ||
861
+ pending.type === 'credential-request'
773
862
  ? pending.toolName
774
863
  : pending.agentName,
775
864
  result: denialResult,
@@ -896,8 +985,12 @@ export async function resumeAIAgent(
896
985
  let isError = false
897
986
  try {
898
987
  toolResult = await matchingTool.execute(toolArgs)
899
- } catch (execErr) {
900
- toolResult = `Error: ${execErr instanceof Error ? execErr.message : String(execErr)}`
988
+ } catch (execErr: any) {
989
+ if (execErr?.payload?.error === 'missing_credential') {
990
+ toolResult = execErr.payload
991
+ } else {
992
+ toolResult = `Error: ${execErr instanceof Error ? execErr.message : String(execErr)}`
993
+ }
901
994
  isError = true
902
995
  }
903
996
 
@@ -1111,6 +1204,17 @@ async function continueAfterToolResult(
1111
1204
  return
1112
1205
  }
1113
1206
 
1207
+ if (loopResult.outcome === 'credential') {
1208
+ await handleCredentialRequests(
1209
+ loopResult.credentialRequests,
1210
+ run.runId,
1211
+ channel,
1212
+ aiRunState,
1213
+ persistingChannel
1214
+ )
1215
+ return
1216
+ }
1217
+
1114
1218
  await postStreamCleanup(
1115
1219
  persistingChannel,
1116
1220
  aiMiddlewares,
@@ -263,6 +263,18 @@ export type AIStreamEvent =
263
263
  agent?: string
264
264
  session?: string
265
265
  }
266
+ | {
267
+ type: 'credential-request'
268
+ toolCallId: string
269
+ toolName: string
270
+ args: unknown
271
+ credentialName: string
272
+ credentialType: 'oauth2' | 'apikey'
273
+ connectUrl?: string
274
+ runId: string
275
+ agent?: string
276
+ session?: string
277
+ }
266
278
  | {
267
279
  type: 'usage'
268
280
  tokens: { input: number; output: number }
@@ -310,6 +322,15 @@ export type PendingApproval =
310
322
  displayToolName: string
311
323
  displayArgs: unknown
312
324
  }
325
+ | {
326
+ type: 'credential-request'
327
+ toolCallId: string
328
+ toolName: string
329
+ args: unknown
330
+ credentialName: string
331
+ credentialType: 'oauth2' | 'apikey'
332
+ connectUrl?: string
333
+ }
313
334
 
314
335
  export interface AgentRunState {
315
336
  runId: string
@@ -318,7 +339,7 @@ export interface AgentRunState {
318
339
  resourceId: string
319
340
  status: 'running' | 'suspended' | 'completed' | 'failed'
320
341
  errorMessage?: string
321
- suspendReason?: 'approval' | 'rpc-missing'
342
+ suspendReason?: 'approval' | 'credential' | 'rpc-missing'
322
343
  missingRpcs?: string[]
323
344
  pendingApprovals?: PendingApproval[]
324
345
  usage: { inputTokens: number; outputTokens: number; model: string }
@@ -10,6 +10,7 @@ export {
10
10
  type RunAIAgentParams,
11
11
  type StreamAIAgentOptions,
12
12
  ToolApprovalRequired,
13
+ ToolCredentialRequired,
13
14
  } from './ai-agent-prepare.js'
14
15
  export {
15
16
  addAIAgent,
@@ -13,7 +13,6 @@ import { pikkuState } from '../../pikku-state.js'
13
13
  import { runPikkuFunc } from '../../function/function-runner.js'
14
14
  import type { SessionService } from '../../services/user-session-service.js'
15
15
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js'
16
-
17
16
  const getChannelMeta = (channelName: string) => {
18
17
  return pikkuState(null, 'channel', 'meta')[channelName]
19
18
  }
@@ -191,7 +191,7 @@ function registerCLICommands(
191
191
  }
192
192
  }
193
193
 
194
- addFunction(funcName, unwrapFunc(command))
194
+ addFunction(funcName, unwrapFunc(command), currentMeta?.packageName)
195
195
 
196
196
  // Register renderer if provided
197
197
  if (typeof command === 'object' && command.render) {
@@ -33,10 +33,6 @@ import {
33
33
  } from '../../pikku-state.js'
34
34
  import { PikkuSessionService } from '../../services/user-session-service.js'
35
35
  import { getErrorResponse } from '../../errors/error-handler.js'
36
- import {
37
- PikkuCredentialWireService,
38
- createMiddlewareCredentialWireProps,
39
- } from '../../services/credential-wire-service.js'
40
36
  import { handleHTTPError } from '../../handle-error.js'
41
37
  import { pikkuState } from '../../pikku-state.js'
42
38
  import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js'
@@ -303,7 +299,6 @@ const executeRoute = async (
303
299
  }
304
300
  ) => {
305
301
  const userSession = new PikkuSessionService<CoreUserSession>()
306
- const credentialWire = new PikkuCredentialWireService()
307
302
  const { params, route, meta } = matchedRoute
308
303
  const { singletonServices, createWireServices, skipUserSession, requestId } =
309
304
  services
@@ -382,7 +377,6 @@ const executeRoute = async (
382
377
  setSession: (s: any) => userSession.setInitial(s),
383
378
  getSession: () => userSession.get(),
384
379
  hasSessionChanged: () => userSession.sessionChanged,
385
- ...createMiddlewareCredentialWireProps(credentialWire),
386
380
  }
387
381
 
388
382
  result = await runPikkuFunc(
@@ -402,7 +396,6 @@ const executeRoute = async (
402
396
  tags: route.tags,
403
397
  wire,
404
398
  sessionService: userSession,
405
- credentialWireService: credentialWire,
406
399
  packageName: meta.packageName,
407
400
  }
408
401
  )
@@ -29,7 +29,6 @@ import {
29
29
  PikkuSessionService,
30
30
  createMiddlewareSessionWireProps,
31
31
  } from '../../services/user-session-service.js'
32
-
33
32
  export class MCPError extends Error {
34
33
  constructor(public readonly error: JsonRpcErrorResponse) {
35
34
  super(error?.message || 'MCP Error')
@@ -12,7 +12,6 @@ import {
12
12
  pikkuState,
13
13
  } from '../../pikku-state.js'
14
14
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js'
15
-
16
15
  /**
17
16
  * Error class for queue processor not found
18
17
  */
@@ -132,6 +131,7 @@ export async function runQueueJob({
132
131
  const queue: PikkuQueue = {
133
132
  queueName: job.queueName,
134
133
  jobId: job.id,
134
+ pikkuUserId: job.pikkuUserId,
135
135
  updateProgress:
136
136
  updateProgress ||
137
137
  (async (progress: number | string | object) => {
@@ -96,6 +96,8 @@ export interface QueueJob<T = any, R = any> {
96
96
  result?: R
97
97
  waitForCompletion?: (ttl?: number) => Promise<R>
98
98
  metadata?: () => Promise<QueueJobMetadata> | QueueJobMetadata
99
+ /** Pikku user ID propagated from the job producer */
100
+ pikkuUserId?: string
99
101
  }
100
102
 
101
103
  /**
@@ -109,6 +111,8 @@ export interface JobOptions {
109
111
  removeOnComplete?: number
110
112
  removeOnFail?: number
111
113
  jobId?: string
114
+ /** Pikku user ID to propagate to the queue worker for credential resolution */
115
+ pikkuUserId?: string
112
116
  }
113
117
 
114
118
  /**
@@ -180,6 +184,8 @@ export interface PikkuQueue {
180
184
  queueName: string
181
185
  /** The current job ID */
182
186
  jobId: string
187
+ /** Pikku user ID propagated from the job producer for credential resolution */
188
+ pikkuUserId?: string
183
189
  /** Update job progress (0-100 or custom value) */
184
190
  updateProgress: (progress: number | string | object) => Promise<void>
185
191
  /** Fail the current job with optional reason */
@@ -239,6 +239,7 @@ export class ContextAwareRPCService {
239
239
  type: this.wire.wireType ?? 'unknown',
240
240
  id: this.wire.wireId,
241
241
  ...(parentRunId ? { parentRunId } : {}),
242
+ ...(this.wire.pikkuUserId ? { pikkuUserId: this.wire.pikkuUserId } : {}),
242
243
  }
243
244
  return this.services.workflowService.startWorkflow(
244
245
  workflowName,
@@ -306,9 +307,14 @@ export class ContextAwareRPCService {
306
307
  approvals: { toolCallId: string; approved: boolean }[],
307
308
  expectedAgentName?: string
308
309
  ) => {
309
- const result = await resumeAIAgentSync(runId, approvals, {
310
- sessionService: this.options.sessionService,
311
- }, expectedAgentName)
310
+ const result = await resumeAIAgentSync(
311
+ runId,
312
+ approvals,
313
+ {
314
+ sessionService: this.options.sessionService,
315
+ },
316
+ expectedAgentName
317
+ )
312
318
  return {
313
319
  runId: result.runId,
314
320
  result: result.object ?? result.text,
@@ -306,6 +306,8 @@ export interface PikkuWorkflowWire {
306
306
  name: string
307
307
  /** The current run ID */
308
308
  runId: string
309
+ /** Pikku user ID propagated from the originating request for credential resolution */
310
+ pikkuUserId?: string
309
311
  /** Get the current workflow run */
310
312
  getRun: () => Promise<WorkflowRun>
311
313
 
@@ -600,8 +600,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
600
600
  runId,
601
601
  rpcService
602
602
  )
603
+ workflowWire.pikkuUserId = run.wire?.pikkuUserId
603
604
  const wire: PikkuWire = {
604
605
  workflow: workflowWire,
606
+ pikkuUserId: run.wire?.pikkuUserId,
605
607
  session: rpcService.wire?.session,
606
608
  rpc: rpcService.wire?.rpc,
607
609
  }
@@ -41,6 +41,8 @@ export interface WorkflowRunWire {
41
41
  id?: string
42
42
  parentRunId?: string
43
43
  parentStepId?: string
44
+ /** Pikku user ID propagated from the originating request for credential resolution */
45
+ pikkuUserId?: string
44
46
  }
45
47
 
46
48
  export interface WorkflowServiceConfig {