@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## 0.12.4
2
2
 
3
+ ## 0.12.14
4
+
5
+ ### Patch Changes
6
+
7
+ - f85c234: Add unified credential system with per-user OAuth and AI agent pre-flight checks
8
+
9
+ - Unified CredentialService with lazy loading per user via pikkuUserId
10
+ - wire.getCredential() for typed single credential lookup
11
+ - MissingCredentialError with structured payload for client-side connect flows
12
+ - Console UI: Global/Users credential tabs, per-user OAuth connect/revoke
13
+ - AI agent pre-flight check: detects missing OAuth credentials from addon metadata, shows "Connect your accounts" prompt before chat
14
+ - CLI codegen: generates credentialsMeta per addon package for runtime lookup
15
+ - Vercel AI runner: catches MissingCredentialError as runtime fallback
16
+
17
+ - 88d3100: Fix CLI command resolution for addon functions by passing packageName to addFunction during registration.
18
+
3
19
  ## 0.12.13
4
20
 
5
21
  ### Patch Changes
@@ -42,6 +42,20 @@ export declare class PaymentRequiredError extends PikkuError {
42
42
  */
43
43
  export declare class ForbiddenError extends PikkuError {
44
44
  }
45
+ /**
46
+ * A required credential is missing. The payload contains metadata
47
+ * about the credential so the client can initiate a connect flow.
48
+ * @group Error
49
+ */
50
+ export declare class MissingCredentialError extends PikkuError {
51
+ payload: {
52
+ error: 'missing_credential';
53
+ credentialName: string;
54
+ credentialType: 'oauth2' | 'apikey';
55
+ connectUrl?: string;
56
+ };
57
+ constructor(credentialName: string, credentialType: 'oauth2' | 'apikey', connectUrl?: string);
58
+ }
45
59
  /**
46
60
  * The session is readonly and cannot access a non-readonly function.
47
61
  * @group Error
@@ -72,6 +72,27 @@ addError(ForbiddenError, {
72
72
  status: 403,
73
73
  message: 'The client does not have permission to access the requested resource.',
74
74
  });
75
+ /**
76
+ * A required credential is missing. The payload contains metadata
77
+ * about the credential so the client can initiate a connect flow.
78
+ * @group Error
79
+ */
80
+ export class MissingCredentialError extends PikkuError {
81
+ payload;
82
+ constructor(credentialName, credentialType, connectUrl) {
83
+ super(`Missing credential: ${credentialName}`);
84
+ this.payload = {
85
+ error: 'missing_credential',
86
+ credentialName,
87
+ credentialType,
88
+ connectUrl,
89
+ };
90
+ }
91
+ }
92
+ addError(MissingCredentialError, {
93
+ status: 403,
94
+ message: 'A required credential is not configured.',
95
+ });
75
96
  /**
76
97
  * The session is readonly and cannot access a non-readonly function.
77
98
  * @group Error
@@ -2,7 +2,7 @@ import type { CoreServices, CoreUserSession, CorePikkuMiddleware, PikkuWiringTyp
2
2
  import type { CorePikkuChannelMiddleware } from '../wirings/channel/channel.types.js';
3
3
  import type { CorePermissionGroup, CorePikkuFunctionConfig, CorePikkuPermission } from './functions.types.js';
4
4
  import type { SessionService } from '../services/user-session-service.js';
5
- import type { PikkuCredentialWireService } from '../services/credential-wire-service.js';
5
+ import { PikkuCredentialWireService } from '../services/credential-wire-service.js';
6
6
  export declare const addFunction: (funcName: string, funcConfig: CorePikkuFunctionConfig<any, any>, packageName?: string | null) => void;
7
7
  export declare const getFunctionNames: (packageName?: string | null) => string[];
8
8
  export declare const getAllFunctionNames: () => string[];
@@ -6,7 +6,7 @@ import { coerceTopLevelDataFromSchema, validateSchema } from '../schema.js';
6
6
  import { parseVersionedId } from '../version.js';
7
7
  import { createFunctionSessionWireProps } from '../services/user-session-service.js';
8
8
  import { ForbiddenError, ReadonlySessionError } from '../errors/errors.js';
9
- import { createWireServicesCredentialWireProps } from '../services/credential-wire-service.js';
9
+ import { PikkuCredentialWireService, createWireServicesCredentialWireProps, } from '../services/credential-wire-service.js';
10
10
  import { rpcService } from '../wirings/rpc/rpc-runner.js';
11
11
  import { closeWireServices } from '../utils.js';
12
12
  /**
@@ -114,6 +114,13 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
114
114
  const resolvedWire = allChannelMiddleware.length > 0 && wire.channel
115
115
  ? wrapChannelWithMiddleware(wire, resolvedSingletonServices, allChannelMiddleware)
116
116
  : wire;
117
+ // Set up credential wire service early so middleware can use setCredential.
118
+ // Skip if already set up (e.g. addon functions reuse the parent wire).
119
+ if (!resolvedWire.getCredentials) {
120
+ const resolvedCredentialWireService = credentialWireService ??
121
+ new PikkuCredentialWireService(resolvedSingletonServices.credentialService, resolvedWire);
122
+ Object.assign(resolvedWire, createWireServicesCredentialWireProps(resolvedCredentialWireService));
123
+ }
117
124
  // Convert tags to PermissionMetadata and merge with inheritedPermissions
118
125
  let mergedInheritedPermissions;
119
126
  if (tags && tags.length > 0) {
@@ -189,9 +196,6 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
189
196
  packageName,
190
197
  });
191
198
  }
192
- if (credentialWireService) {
193
- Object.assign(resolvedWire, createWireServicesCredentialWireProps(credentialWireService));
194
- }
195
199
  const wireServices = await resolvedCreateWireServices?.(resolvedSingletonServices, resolvedWire);
196
200
  try {
197
201
  const services = wireServices && Object.keys(wireServices).length > 0
@@ -127,6 +127,7 @@ const createEmptyPackageState = () => ({
127
127
  factories: null,
128
128
  singletonServices: null,
129
129
  metaDir: null,
130
+ credentialsMeta: null,
130
131
  },
131
132
  });
132
133
  /**
@@ -37,4 +37,15 @@ export interface CredentialService {
37
37
  * @returns A record of credential name to value.
38
38
  */
39
39
  getAll(userId: string): Promise<Record<string, unknown>>;
40
+ /**
41
+ * Lists all user IDs that have a specific credential configured.
42
+ * @param name - The credential name.
43
+ * @returns Array of user IDs.
44
+ */
45
+ getUsersWithCredential(name: string): Promise<string[]>;
46
+ /**
47
+ * Lists all unique user IDs that have any credential configured.
48
+ * @returns Array of user IDs.
49
+ */
50
+ getAllUsers(): Promise<string[]>;
40
51
  }
@@ -1,13 +1,24 @@
1
+ import type { CredentialService } from './credential-service.js';
2
+ import type { PikkuWire } from '../types/core.types.js';
1
3
  export declare class PikkuCredentialWireService {
4
+ private credentialService?;
5
+ private wire?;
2
6
  private credentials;
7
+ private loaded;
8
+ private loadPromise;
9
+ constructor(credentialService?: CredentialService | undefined, wire?: PikkuWire | undefined);
3
10
  set(name: string, value: unknown): void;
4
- getAll(): Record<string, unknown>;
5
- getScoped(allowedNames: string[]): Record<string, unknown>;
11
+ get<T = unknown>(name: string): T | null | Promise<T | null>;
12
+ getAll(): Record<string, unknown> | Promise<Record<string, unknown>>;
13
+ getScoped(allowedNames: string[]): Record<string, unknown> | Promise<Record<string, unknown>>;
14
+ private lazyLoad;
15
+ private doLoad;
6
16
  }
7
17
  export declare function createMiddlewareCredentialWireProps(credentialWire: PikkuCredentialWireService): {
8
18
  setCredential: (name: string, value: unknown) => void;
9
19
  };
10
20
  export declare function createWireServicesCredentialWireProps(credentialWire: PikkuCredentialWireService, allowedNames?: string[]): {
11
21
  setCredential: (name: string, value: unknown) => void;
12
- getCredentials: () => Record<string, unknown>;
22
+ getCredential: <T = unknown>(name: string) => T | Promise<T | null> | null;
23
+ getCredentials: () => Record<string, unknown> | Promise<Record<string, unknown>>;
13
24
  };
@@ -1,19 +1,61 @@
1
+ import { defaultPikkuUserIdResolver } from './pikku-user-id.js';
1
2
  export class PikkuCredentialWireService {
3
+ credentialService;
4
+ wire;
2
5
  credentials = {};
6
+ loaded = false;
7
+ loadPromise;
8
+ constructor(credentialService, wire) {
9
+ this.credentialService = credentialService;
10
+ this.wire = wire;
11
+ }
3
12
  set(name, value) {
4
13
  this.credentials[name] = value;
5
14
  }
15
+ get(name) {
16
+ if (this.loaded)
17
+ return this.credentials[name] ?? null;
18
+ return this.lazyLoad().then(() => this.credentials[name] ?? null);
19
+ }
6
20
  getAll() {
7
- return this.credentials;
21
+ if (this.loaded)
22
+ return this.credentials;
23
+ return this.lazyLoad().then(() => this.credentials);
8
24
  }
9
25
  getScoped(allowedNames) {
10
- const scoped = {};
11
- for (const name of allowedNames) {
12
- if (name in this.credentials) {
13
- scoped[name] = this.credentials[name];
26
+ const buildScoped = () => {
27
+ const scoped = {};
28
+ for (const name of allowedNames) {
29
+ if (name in this.credentials) {
30
+ scoped[name] = this.credentials[name];
31
+ }
32
+ }
33
+ return scoped;
34
+ };
35
+ if (this.loaded)
36
+ return buildScoped();
37
+ return this.lazyLoad().then(buildScoped);
38
+ }
39
+ lazyLoad() {
40
+ if (this.loadPromise)
41
+ return this.loadPromise;
42
+ this.loadPromise = this.doLoad();
43
+ return this.loadPromise;
44
+ }
45
+ async doLoad() {
46
+ this.loaded = true;
47
+ if (!this.credentialService || !this.wire)
48
+ return;
49
+ const userId = defaultPikkuUserIdResolver(this.wire);
50
+ if (!userId)
51
+ return;
52
+ this.wire.pikkuUserId = userId;
53
+ const allCreds = await this.credentialService.getAll(userId);
54
+ for (const [name, value] of Object.entries(allCreds)) {
55
+ if (!(name in this.credentials)) {
56
+ this.credentials[name] = value;
14
57
  }
15
58
  }
16
- return scoped;
17
59
  }
18
60
  }
19
61
  export function createMiddlewareCredentialWireProps(credentialWire) {
@@ -24,6 +66,7 @@ export function createMiddlewareCredentialWireProps(credentialWire) {
24
66
  export function createWireServicesCredentialWireProps(credentialWire, allowedNames) {
25
67
  return {
26
68
  setCredential: (name, value) => credentialWire.set(name, value),
69
+ getCredential: (name) => credentialWire.get(name),
27
70
  getCredentials: () => allowedNames
28
71
  ? credentialWire.getScoped(allowedNames)
29
72
  : credentialWire.getAll(),
@@ -7,4 +7,6 @@ export declare class LocalCredentialService implements CredentialService {
7
7
  delete(name: string, userId?: string): Promise<void>;
8
8
  has(name: string, userId?: string): Promise<boolean>;
9
9
  getAll(userId: string): Promise<Record<string, unknown>>;
10
+ getUsersWithCredential(name: string): Promise<string[]>;
11
+ getAllUsers(): Promise<string[]>;
10
12
  }
@@ -30,4 +30,24 @@ export class LocalCredentialService {
30
30
  }
31
31
  return result;
32
32
  }
33
+ async getUsersWithCredential(name) {
34
+ const suffix = `:${name}`;
35
+ const users = [];
36
+ for (const key of this.store.keys()) {
37
+ if (key.endsWith(suffix)) {
38
+ users.push(key.slice(0, -suffix.length));
39
+ }
40
+ }
41
+ return users;
42
+ }
43
+ async getAllUsers() {
44
+ const users = new Set();
45
+ for (const key of this.store.keys()) {
46
+ const colonIndex = key.indexOf(':');
47
+ if (colonIndex > 0) {
48
+ users.add(key.slice(0, colonIndex));
49
+ }
50
+ }
51
+ return [...users];
52
+ }
33
53
  }
@@ -0,0 +1,3 @@
1
+ import type { PikkuWire } from '../types/core.types.js';
2
+ export type PikkuUserIdResolver = (wire: PikkuWire) => string | undefined;
3
+ export declare const defaultPikkuUserIdResolver: PikkuUserIdResolver;
@@ -0,0 +1,16 @@
1
+ export const defaultPikkuUserIdResolver = (wire) => {
2
+ // Explicit pikkuUserId on wire (set by earlier middleware or runner)
3
+ if (wire.pikkuUserId)
4
+ return wire.pikkuUserId;
5
+ // Session userId (from auth middleware — user-defined session shape)
6
+ const session = wire.session;
7
+ if (session?.userId && typeof session.userId === 'string')
8
+ return session.userId;
9
+ // Queue job: carried in job metadata
10
+ if (wire.queue?.pikkuUserId)
11
+ return wire.queue.pikkuUserId;
12
+ // Workflow: carried in run wire metadata
13
+ if (wire.workflow?.pikkuUserId)
14
+ return wire.workflow.pikkuUserId;
15
+ return undefined;
16
+ };
@@ -22,6 +22,8 @@ export declare class TypedCredentialService<TMap = Record<string, unknown>> impl
22
22
  delete(name: string, userId?: string): Promise<void>;
23
23
  has(name: string, userId?: string): Promise<boolean>;
24
24
  getAll(userId: string): Promise<Record<string, unknown>>;
25
+ getUsersWithCredential(name: string): Promise<string[]>;
26
+ getAllUsers(): Promise<string[]>;
25
27
  getAllStatus(userId?: string): Promise<CredentialStatusInfo[]>;
26
28
  getMissing(userId?: string): Promise<CredentialStatusInfo[]>;
27
29
  }
@@ -20,6 +20,12 @@ export class TypedCredentialService {
20
20
  async getAll(userId) {
21
21
  return this.credentials.getAll(userId);
22
22
  }
23
+ async getUsersWithCredential(name) {
24
+ return this.credentials.getUsersWithCredential(name);
25
+ }
26
+ async getAllUsers() {
27
+ return this.credentials.getAllUsers();
28
+ }
23
29
  async getAllStatus(userId) {
24
30
  const results = [];
25
31
  for (const [name, meta] of Object.entries(this.credentialsMeta)) {
@@ -203,10 +203,14 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
203
203
  getSession: () => Promise<UserSession> | UserSession | undefined;
204
204
  /** Whether the session was modified during this run */
205
205
  hasSessionChanged: () => boolean;
206
+ /** The resolved user identity for credential lookups */
207
+ pikkuUserId: string;
206
208
  /** Set a credential value (available in middleware) */
207
209
  setCredential: (name: string, value: unknown) => void;
208
- /** Get all resolved credentials (only available in pikkuWireServices) */
209
- getCredentials: () => Record<string, unknown>;
210
+ /** Get a single credential by name lazy-loads from CredentialService on first call, sync thereafter */
211
+ getCredential: <T = unknown>(name: string) => T | null | Promise<T | null>;
212
+ /** Get all resolved credentials — lazy-loads from CredentialService on first call, sync thereafter */
213
+ getCredentials: () => Record<string, unknown> | Promise<Record<string, unknown>>;
210
214
  }>;
211
215
  /**
212
216
  * A function that can wrap an wire and be called before or after
@@ -132,5 +132,12 @@ export interface PikkuPackageState {
132
132
  singletonServices: CoreSingletonServices | null;
133
133
  /** Absolute path to this package's .pikku directory */
134
134
  metaDir: string | null;
135
+ /** Credential metadata for this addon package */
136
+ credentialsMeta: Record<string, {
137
+ name: string;
138
+ displayName: string;
139
+ type: string;
140
+ oauth2?: boolean;
141
+ }> | null;
135
142
  };
136
143
  }
@@ -19,6 +19,27 @@ export declare class ToolApprovalRequired extends PikkuError {
19
19
  readonly agentRunId?: string;
20
20
  constructor(toolCallId: string, toolName: string, args: unknown, reason?: string, displayToolName?: string, displayArgs?: unknown, agentRunId?: string);
21
21
  }
22
+ export declare class ToolCredentialRequired extends PikkuError {
23
+ readonly toolCallId: string;
24
+ readonly toolName: string;
25
+ readonly args: unknown;
26
+ readonly credentialName: string;
27
+ readonly credentialType: 'oauth2' | 'apikey';
28
+ readonly connectUrl?: string;
29
+ constructor(toolCallId: string, toolName: string, args: unknown, credentialName: string, credentialType: 'oauth2' | 'apikey', connectUrl?: string);
30
+ }
31
+ export interface AddonCredentialRequirement {
32
+ credentialName: string;
33
+ displayName: string;
34
+ addonNamespace: string;
35
+ type: 'wire';
36
+ oauth2: boolean;
37
+ }
38
+ /**
39
+ * Given a list of tool names (e.g. ["oauth-api:getProfile"]),
40
+ * returns the wire OAuth credentials required by their addons.
41
+ */
42
+ export declare function getAddonCredentialRequirements(toolNames: string[]): AddonCredentialRequirement[];
22
43
  export type StreamContext = {
23
44
  channel: AIStreamChannel;
24
45
  options?: StreamAIAgentOptions;
@@ -28,6 +28,52 @@ export class ToolApprovalRequired extends PikkuError {
28
28
  this.agentRunId = agentRunId;
29
29
  }
30
30
  }
31
+ export class ToolCredentialRequired extends PikkuError {
32
+ toolCallId;
33
+ toolName;
34
+ args;
35
+ credentialName;
36
+ credentialType;
37
+ connectUrl;
38
+ constructor(toolCallId, toolName, args, credentialName, credentialType, connectUrl) {
39
+ super(`Tool '${toolName}' requires credential '${credentialName}'`);
40
+ this.toolCallId = toolCallId;
41
+ this.toolName = toolName;
42
+ this.args = args;
43
+ this.credentialName = credentialName;
44
+ this.credentialType = credentialType;
45
+ this.connectUrl = connectUrl;
46
+ }
47
+ }
48
+ /**
49
+ * Given a list of tool names (e.g. ["oauth-api:getProfile"]),
50
+ * returns the wire OAuth credentials required by their addons.
51
+ */
52
+ export function getAddonCredentialRequirements(toolNames) {
53
+ const requirements = new Map();
54
+ for (const toolName of toolNames) {
55
+ if (!toolName.includes(':'))
56
+ continue;
57
+ const resolved = resolveNamespace(toolName);
58
+ if (!resolved)
59
+ continue;
60
+ const credsMeta = pikkuState(resolved.package, 'package', 'credentialsMeta');
61
+ if (!credsMeta)
62
+ continue;
63
+ for (const [name, meta] of Object.entries(credsMeta)) {
64
+ if (meta.type === 'wire' && meta.oauth2 && !requirements.has(name)) {
65
+ requirements.set(name, {
66
+ credentialName: name,
67
+ displayName: meta.displayName ?? name,
68
+ addonNamespace: toolName.split(':')[0],
69
+ type: 'wire',
70
+ oauth2: true,
71
+ });
72
+ }
73
+ }
74
+ }
75
+ return [...requirements.values()];
76
+ }
31
77
  export const resolveAgent = (agentName) => {
32
78
  const mainAgent = pikkuState(null, 'agent', 'agents').get(agentName);
33
79
  if (mainAgent) {
@@ -324,6 +370,8 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
324
370
  }
325
371
  catch (err) {
326
372
  execError = err;
373
+ if (err?.payload?.error === 'missing_credential')
374
+ throw err;
327
375
  result = err instanceof Error ? err.message : String(err);
328
376
  }
329
377
  const durationMs = Date.now() - startTime;
@@ -298,7 +298,12 @@ export async function resumeAIAgentSync(runId, approvals, params, expectedAgentN
298
298
  : JSON.stringify(toolResult);
299
299
  }
300
300
  catch (err) {
301
- resultStr = `Error: ${err instanceof Error ? err.message : String(err)}`;
301
+ if (err?.payload?.error === 'missing_credential') {
302
+ resultStr = JSON.stringify(err.payload);
303
+ }
304
+ else {
305
+ resultStr = `Error: ${err instanceof Error ? err.message : String(err)}`;
306
+ }
302
307
  }
303
308
  }
304
309
  else {
@@ -1,7 +1,8 @@
1
1
  import type { AIStreamChannel } from './ai-agent.types.js';
2
2
  import type { AIAgentRunnerParams, AIAgentStepResult } from '../../services/ai-agent-runner-service.js';
3
- import { ToolApprovalRequired, type RunAIAgentParams, type StreamAIAgentOptions } from './ai-agent-prepare.js';
3
+ import { ToolApprovalRequired, ToolCredentialRequired, type RunAIAgentParams, type StreamAIAgentOptions } from './ai-agent-prepare.js';
4
4
  export declare function checkForApprovals(stepResult: AIAgentStepResult, tools: AIAgentRunnerParams['tools'], runId: string): ToolApprovalRequired[];
5
+ export declare function checkForCredentialRequests(stepResult: AIAgentStepResult, runId: string): ToolCredentialRequired[];
5
6
  export declare function appendStepMessages(runnerParams: AIAgentRunnerParams, stepResult: AIAgentStepResult): void;
6
7
  export declare function streamAIAgent(agentName: string, input: {
7
8
  message: string;
@@ -3,7 +3,7 @@ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
3
3
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
4
4
  import { randomUUID } from 'crypto';
5
5
  import { parseWorkingMemory, deepMergeWorkingMemory, resolveMemoryServices, loadContextMessages, trimMessages, } from './ai-agent-memory.js';
6
- import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, ToolApprovalRequired, } from './ai-agent-prepare.js';
6
+ import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
7
7
  import { resolveModelConfig } from './ai-agent-model-config.js';
8
8
  function createPersistingChannel(parent, storage, threadId) {
9
9
  let fullText = '';
@@ -197,6 +197,12 @@ async function runStreamStepLoop(params) {
197
197
  }
198
198
  return { outcome: 'approval', approvals: approvalsNeeded };
199
199
  }
200
+ const credentialRequests = checkForCredentialRequests(stepResult, runId);
201
+ if (credentialRequests.length > 0) {
202
+ // Append step messages so the tool result is preserved for resume
203
+ appendStepMessages(runnerParams, stepResult);
204
+ return { outcome: 'credential', credentialRequests };
205
+ }
200
206
  appendStepMessages(runnerParams, stepResult);
201
207
  }
202
208
  return { outcome: 'done' };
@@ -226,6 +232,19 @@ export function checkForApprovals(stepResult, tools, runId) {
226
232
  }
227
233
  return approvals;
228
234
  }
235
+ export function checkForCredentialRequests(stepResult, runId) {
236
+ const requests = [];
237
+ for (const tr of stepResult.toolResults) {
238
+ if (tr.result &&
239
+ typeof tr.result === 'object' &&
240
+ '__credentialRequired' in tr.result) {
241
+ const r = tr.result;
242
+ const tc = stepResult.toolCalls.find((t) => t.toolCallId === tr.toolCallId);
243
+ requests.push(new ToolCredentialRequired(tr.toolCallId, tc?.toolName ?? 'unknown', tc?.args ?? {}, r.credentialName, r.credentialType, r.connectUrl));
244
+ }
245
+ }
246
+ return requests;
247
+ }
229
248
  export function appendStepMessages(runnerParams, stepResult) {
230
249
  const assistantMsg = {
231
250
  id: randomUUID(),
@@ -293,6 +312,30 @@ function handleApprovals(approvals, runId, channel, aiRunState, persistingChanne
293
312
  channel.close();
294
313
  })();
295
314
  }
315
+ function handleCredentialRequests(requests, runId, channel, aiRunState, persistingChannel) {
316
+ return (async () => {
317
+ await persistingChannel.flush();
318
+ const pendingApprovals = requests.map((req) => ({
319
+ type: 'credential-request',
320
+ toolCallId: req.toolCallId,
321
+ toolName: req.toolName,
322
+ args: req.args,
323
+ credentialName: req.credentialName,
324
+ credentialType: req.credentialType,
325
+ connectUrl: req.connectUrl,
326
+ }));
327
+ await aiRunState.updateRun(runId, {
328
+ status: 'suspended',
329
+ suspendReason: 'credential',
330
+ pendingApprovals,
331
+ });
332
+ // Don't send credential-request SSE events — the tool result with
333
+ // __credentialRequired was already streamed. The frontend detects it
334
+ // from the tool result and shows Connect/Ignore buttons.
335
+ channel.send({ type: 'done' });
336
+ channel.close();
337
+ })();
338
+ }
296
339
  export async function streamAIAgent(agentName, input, channel, params, agentSessionMap, options) {
297
340
  const sessionMap = agentSessionMap ?? new Map();
298
341
  const normalizedInput = input;
@@ -419,6 +462,10 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
419
462
  await handleApprovals(loopResult.approvals, runId, channel, aiRunState, persistingChannel);
420
463
  return persistingChannel.fullText;
421
464
  }
465
+ if (loopResult.outcome === 'credential') {
466
+ await handleCredentialRequests(loopResult.credentialRequests, runId, channel, aiRunState, persistingChannel);
467
+ return persistingChannel.fullText;
468
+ }
422
469
  await postStreamCleanup(persistingChannel, aiMiddlewares, singletonServices, storage, memoryConfig, threadId, workingMemorySchemaName, runnerParams.messages, aiRunState, runId);
423
470
  channel.send({ type: 'done' });
424
471
  channel.close();
@@ -487,7 +534,8 @@ export async function resumeAIAgent(input, channel, params, options) {
487
534
  toolResults: [
488
535
  {
489
536
  id: input.toolCallId,
490
- name: pending.type === 'tool-call'
537
+ name: pending.type === 'tool-call' ||
538
+ pending.type === 'credential-request'
491
539
  ? pending.toolName
492
540
  : pending.agentName,
493
541
  result: denialResult,
@@ -573,7 +621,12 @@ export async function resumeAIAgent(input, channel, params, options) {
573
621
  toolResult = await matchingTool.execute(toolArgs);
574
622
  }
575
623
  catch (execErr) {
576
- toolResult = `Error: ${execErr instanceof Error ? execErr.message : String(execErr)}`;
624
+ if (execErr?.payload?.error === 'missing_credential') {
625
+ toolResult = execErr.payload;
626
+ }
627
+ else {
628
+ toolResult = `Error: ${execErr instanceof Error ? execErr.message : String(execErr)}`;
629
+ }
577
630
  isError = true;
578
631
  }
579
632
  const resultStr = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
@@ -708,6 +761,10 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
708
761
  await handleApprovals(loopResult.approvals, run.runId, channel, aiRunState, persistingChannel);
709
762
  return;
710
763
  }
764
+ if (loopResult.outcome === 'credential') {
765
+ await handleCredentialRequests(loopResult.credentialRequests, run.runId, channel, aiRunState, persistingChannel);
766
+ return;
767
+ }
711
768
  await postStreamCleanup(persistingChannel, aiMiddlewares, singletonServices, storage, memoryConfig, run.threadId, workingMemorySchemaName, runnerParams.messages, aiRunState, run.runId);
712
769
  channel.send({ type: 'done' });
713
770
  channel.close();
@@ -256,6 +256,17 @@ export type AIStreamEvent = {
256
256
  reason?: string;
257
257
  agent?: string;
258
258
  session?: string;
259
+ } | {
260
+ type: 'credential-request';
261
+ toolCallId: string;
262
+ toolName: string;
263
+ args: unknown;
264
+ credentialName: string;
265
+ credentialType: 'oauth2' | 'apikey';
266
+ connectUrl?: string;
267
+ runId: string;
268
+ agent?: string;
269
+ session?: string;
259
270
  } | {
260
271
  type: 'usage';
261
272
  tokens: {
@@ -307,6 +318,14 @@ export type PendingApproval = {
307
318
  agentRunId: string;
308
319
  displayToolName: string;
309
320
  displayArgs: unknown;
321
+ } | {
322
+ type: 'credential-request';
323
+ toolCallId: string;
324
+ toolName: string;
325
+ args: unknown;
326
+ credentialName: string;
327
+ credentialType: 'oauth2' | 'apikey';
328
+ connectUrl?: string;
310
329
  };
311
330
  export interface AgentRunState {
312
331
  runId: string;
@@ -315,7 +334,7 @@ export interface AgentRunState {
315
334
  resourceId: string;
316
335
  status: 'running' | 'suspended' | 'completed' | 'failed';
317
336
  errorMessage?: string;
318
- suspendReason?: 'approval' | 'rpc-missing';
337
+ suspendReason?: 'approval' | 'credential' | 'rpc-missing';
319
338
  missingRpcs?: string[];
320
339
  pendingApprovals?: PendingApproval[];
321
340
  usage: {
@@ -1,6 +1,6 @@
1
1
  export { agent, agentStream, agentResume, agentApprove, } from './ai-agent-helpers.js';
2
2
  export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js';
3
3
  export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js';
4
- export { type RunAIAgentParams, type StreamAIAgentOptions, ToolApprovalRequired, } from './ai-agent-prepare.js';
4
+ export { type RunAIAgentParams, type StreamAIAgentOptions, ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
5
5
  export { addAIAgent, approveAIAgent, getAIAgents, getAIAgentsMeta, } from './ai-agent-registry.js';
6
6
  export type { AIAgentInput, AIAgentInputAttachment, AIAgentMeta, AIAgentMemoryConfig, AIAgentStep, AIContentPart, AgentRunRow, AgentRunService, AgentRunState, AIMessage, AIStreamChannel, AIStreamEvent, AIThread, CoreAIAgent, PendingApproval, PikkuAIMiddlewareHooks, } from './ai-agent.types.js';
@@ -1,5 +1,5 @@
1
1
  export { agent, agentStream, agentResume, agentApprove, } from './ai-agent-helpers.js';
2
2
  export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js';
3
3
  export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js';
4
- export { ToolApprovalRequired, } from './ai-agent-prepare.js';
4
+ export { ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
5
5
  export { addAIAgent, approveAIAgent, getAIAgents, getAIAgentsMeta, } from './ai-agent-registry.js';