@pikku/core 0.12.8 → 0.12.10

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 (98) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/env.d.ts +1 -0
  3. package/dist/env.js +1 -0
  4. package/dist/errors/errors.d.ts +14 -0
  5. package/dist/errors/errors.js +27 -0
  6. package/dist/function/function-runner.d.ts +3 -1
  7. package/dist/function/function-runner.js +5 -1
  8. package/dist/handle-error.js +2 -1
  9. package/dist/middleware/auth-bearer.js +10 -1
  10. package/dist/middleware/auth-cookie.js +34 -25
  11. package/dist/middleware/timeout.js +11 -5
  12. package/dist/pikku-state.js +1 -1
  13. package/dist/services/credential-service.d.ts +40 -0
  14. package/dist/services/credential-service.js +1 -0
  15. package/dist/services/credential-wire-service.d.ts +13 -0
  16. package/dist/services/credential-wire-service.js +31 -0
  17. package/dist/services/index.d.ts +5 -0
  18. package/dist/services/index.js +3 -0
  19. package/dist/services/local-content.d.ts +7 -4
  20. package/dist/services/local-content.js +42 -12
  21. package/dist/services/local-credential-service.d.ts +10 -0
  22. package/dist/services/local-credential-service.js +33 -0
  23. package/dist/services/local-secrets.js +2 -2
  24. package/dist/services/typed-credential-service.d.ts +27 -0
  25. package/dist/services/typed-credential-service.js +40 -0
  26. package/dist/testing/service-tests.d.ts +8 -0
  27. package/dist/testing/service-tests.js +107 -1
  28. package/dist/types/core.types.d.ts +7 -0
  29. package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
  30. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
  31. package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
  32. package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
  33. package/dist/wirings/channel/local/local-channel-runner.js +11 -6
  34. package/dist/wirings/credential/credential.types.d.ts +24 -0
  35. package/dist/wirings/credential/credential.types.js +1 -0
  36. package/dist/wirings/credential/index.d.ts +3 -0
  37. package/dist/wirings/credential/index.js +2 -0
  38. package/dist/wirings/credential/validate-credential-definitions.d.ts +6 -0
  39. package/dist/wirings/credential/validate-credential-definitions.js +38 -0
  40. package/dist/wirings/credential/wire-credential.d.ts +48 -0
  41. package/dist/wirings/credential/wire-credential.js +47 -0
  42. package/dist/wirings/http/http-runner.js +8 -2
  43. package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
  44. package/dist/wirings/oauth2/index.d.ts +2 -1
  45. package/dist/wirings/oauth2/index.js +1 -1
  46. package/dist/wirings/oauth2/oauth2-client.js +4 -8
  47. package/dist/wirings/oauth2/oauth2-routes.d.ts +35 -0
  48. package/dist/wirings/oauth2/oauth2-routes.js +146 -0
  49. package/dist/wirings/oauth2/oauth2.types.d.ts +0 -26
  50. package/dist/wirings/workflow/graph/graph-runner.d.ts +8 -1
  51. package/dist/wirings/workflow/graph/graph-runner.js +87 -7
  52. package/dist/wirings/workflow/pikku-workflow-service.d.ts +5 -2
  53. package/dist/wirings/workflow/pikku-workflow-service.js +99 -24
  54. package/dist/wirings/workflow/workflow.types.d.ts +4 -0
  55. package/package.json +2 -1
  56. package/src/env.ts +1 -0
  57. package/src/errors/errors.ts +35 -0
  58. package/src/function/function-runner.ts +11 -0
  59. package/src/handle-error.ts +2 -1
  60. package/src/middleware/auth-bearer.ts +10 -1
  61. package/src/middleware/auth-cookie.ts +11 -4
  62. package/src/middleware/timeout.ts +14 -7
  63. package/src/pikku-state.ts +1 -1
  64. package/src/services/credential-service.ts +44 -0
  65. package/src/services/credential-wire-service.ts +44 -0
  66. package/src/services/index.ts +12 -0
  67. package/src/services/local-content.ts +61 -13
  68. package/src/services/local-credential-service.ts +41 -0
  69. package/src/services/local-secrets.test.ts +2 -2
  70. package/src/services/local-secrets.ts +2 -2
  71. package/src/services/typed-credential-service.ts +75 -0
  72. package/src/testing/service-tests.ts +140 -1
  73. package/src/types/core.types.ts +7 -0
  74. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
  75. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
  76. package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
  77. package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
  78. package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
  79. package/src/wirings/channel/local/local-channel-runner.ts +11 -6
  80. package/src/wirings/credential/credential.types.ts +28 -0
  81. package/src/wirings/credential/index.ts +8 -0
  82. package/src/wirings/credential/validate-credential-definitions.ts +64 -0
  83. package/src/wirings/credential/wire-credential.ts +49 -0
  84. package/src/wirings/http/http-runner.ts +11 -2
  85. package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
  86. package/src/wirings/oauth2/index.ts +2 -1
  87. package/src/wirings/oauth2/oauth2-client.ts +4 -8
  88. package/src/wirings/oauth2/oauth2-routes.ts +234 -0
  89. package/src/wirings/oauth2/oauth2.types.ts +0 -27
  90. package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
  91. package/src/wirings/workflow/graph/graph-runner.ts +117 -8
  92. package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
  93. package/src/wirings/workflow/pikku-workflow-service.ts +150 -34
  94. package/src/wirings/workflow/workflow.types.ts +4 -0
  95. package/tsconfig.tsbuildinfo +1 -1
  96. package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +0 -20
  97. package/dist/wirings/oauth2/wire-oauth2-credential.js +0 -21
  98. package/src/wirings/oauth2/wire-oauth2-credential.ts +0 -23
@@ -0,0 +1,40 @@
1
+ export class TypedCredentialService {
2
+ credentials;
3
+ credentialsMeta;
4
+ constructor(credentials, credentialsMeta) {
5
+ this.credentials = credentials;
6
+ this.credentialsMeta = credentialsMeta;
7
+ }
8
+ async get(name, userId) {
9
+ return this.credentials.get(name, userId);
10
+ }
11
+ async set(name, value, userId) {
12
+ return this.credentials.set(name, value, userId);
13
+ }
14
+ async delete(name, userId) {
15
+ return this.credentials.delete(name, userId);
16
+ }
17
+ async has(name, userId) {
18
+ return this.credentials.has(name, userId);
19
+ }
20
+ async getAll(userId) {
21
+ return this.credentials.getAll(userId);
22
+ }
23
+ async getAllStatus(userId) {
24
+ const results = [];
25
+ for (const [name, meta] of Object.entries(this.credentialsMeta)) {
26
+ results.push({
27
+ name,
28
+ displayName: meta.displayName,
29
+ isConfigured: await this.credentials.has(name, userId),
30
+ type: meta.type,
31
+ oauth2: meta.oauth2,
32
+ });
33
+ }
34
+ return results;
35
+ }
36
+ async getMissing(userId) {
37
+ const all = await this.getAllStatus(userId);
38
+ return all.filter((c) => !c.isConfigured);
39
+ }
40
+ }
@@ -6,6 +6,7 @@ import type { DeploymentService } from '../services/deployment-service.js';
6
6
  import type { AIStorageService } from '../services/ai-storage-service.js';
7
7
  import type { AIRunStateService } from '../services/ai-run-state-service.js';
8
8
  import type { SecretService } from '../services/secret-service.js';
9
+ import type { CredentialService } from '../services/credential-service.js';
9
10
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
10
11
  export interface ServiceTestConfig {
11
12
  name: string;
@@ -26,6 +27,13 @@ export interface ServiceTestConfig {
26
27
  }) => Promise<SecretService & {
27
28
  rotateKEK?(): Promise<number>;
28
29
  }>;
30
+ credentialService?: (config: {
31
+ key: string;
32
+ keyVersion?: number;
33
+ previousKey?: string;
34
+ }) => Promise<CredentialService & {
35
+ rotateKEK?(): Promise<number>;
36
+ }>;
29
37
  };
30
38
  }
31
39
  export declare function defineServiceTests(config: ServiceTestConfig): void;
@@ -523,7 +523,7 @@ export function defineServiceTests(config) {
523
523
  test('getSecret throws for missing key', async () => {
524
524
  const service = await factory({ key: kek });
525
525
  await assert.rejects(() => service.getSecret('nonexistent'), {
526
- message: 'Secret not found: nonexistent',
526
+ message: 'Requested secret not found',
527
527
  });
528
528
  });
529
529
  test('setSecretJSON upserts existing key', async () => {
@@ -570,6 +570,112 @@ export function defineServiceTests(config) {
570
570
  });
571
571
  });
572
572
  }
573
+ if (services.credentialService) {
574
+ const factory = services.credentialService;
575
+ const kek = 'test-key-encryption-key-32chars!';
576
+ describe(`CredentialService [${name}]`, () => {
577
+ test('set and get (global)', async () => {
578
+ const service = await factory({ key: kek });
579
+ await service.set('stripe', { apiKey: 'sk-123' });
580
+ const result = await service.get('stripe');
581
+ assert.deepEqual(result, { apiKey: 'sk-123' });
582
+ });
583
+ test('get returns null for missing', async () => {
584
+ const service = await factory({ key: kek });
585
+ const result = await service.get('nonexistent');
586
+ assert.equal(result, null);
587
+ });
588
+ test('has returns true/false', async () => {
589
+ const service = await factory({ key: kek });
590
+ await service.set('exists', { key: 'val' });
591
+ assert.strictEqual(await service.has('exists'), true);
592
+ assert.strictEqual(await service.has('nope'), false);
593
+ });
594
+ test('set upserts existing credential', async () => {
595
+ const service = await factory({ key: kek });
596
+ await service.set('upsert', { v: 1 });
597
+ await service.set('upsert', { v: 2 });
598
+ const result = await service.get('upsert');
599
+ assert.deepEqual(result, { v: 2 });
600
+ });
601
+ test('delete removes credential', async () => {
602
+ const service = await factory({ key: kek });
603
+ await service.set('to-delete', { bye: true });
604
+ assert.strictEqual(await service.has('to-delete'), true);
605
+ await service.delete('to-delete');
606
+ assert.strictEqual(await service.has('to-delete'), false);
607
+ });
608
+ test('per-user isolation', async () => {
609
+ const service = await factory({ key: kek });
610
+ await service.set('token', { access: 'user1' }, 'user-1');
611
+ await service.set('token', { access: 'user2' }, 'user-2');
612
+ const u1 = await service.get('token', 'user-1');
613
+ const u2 = await service.get('token', 'user-2');
614
+ assert.deepEqual(u1, { access: 'user1' });
615
+ assert.deepEqual(u2, { access: 'user2' });
616
+ });
617
+ test('per-user delete does not affect other users', async () => {
618
+ const service = await factory({ key: kek });
619
+ await service.set('cred', { val: 'a' }, 'user-a');
620
+ await service.set('cred', { val: 'b' }, 'user-b');
621
+ await service.delete('cred', 'user-a');
622
+ assert.strictEqual(await service.has('cred', 'user-a'), false);
623
+ assert.strictEqual(await service.has('cred', 'user-b'), true);
624
+ });
625
+ test('global and per-user are separate', async () => {
626
+ const service = await factory({ key: kek });
627
+ await service.set('shared', { scope: 'global' });
628
+ await service.set('shared', { scope: 'user' }, 'user-1');
629
+ const global = await service.get('shared');
630
+ const perUser = await service.get('shared', 'user-1');
631
+ assert.deepEqual(global, { scope: 'global' });
632
+ assert.deepEqual(perUser, { scope: 'user' });
633
+ });
634
+ test('getAll returns all credentials for a user', async () => {
635
+ const service = await factory({ key: kek });
636
+ await service.set('cred-a', { a: 1 }, 'user-x');
637
+ await service.set('cred-b', { b: 2 }, 'user-x');
638
+ await service.set('cred-c', { c: 3 }, 'user-y');
639
+ const all = await service.getAll('user-x');
640
+ assert.deepEqual(all['cred-a'], { a: 1 });
641
+ assert.deepEqual(all['cred-b'], { b: 2 });
642
+ assert.strictEqual(all['cred-c'], undefined);
643
+ });
644
+ test('getAll returns empty for unknown user', async () => {
645
+ const service = await factory({ key: kek });
646
+ const all = await service.getAll('nobody');
647
+ assert.deepEqual(all, {});
648
+ });
649
+ test('rotateKEK re-wraps all credentials', async () => {
650
+ const newKEK = 'new-key-encryption-key-rotated!';
651
+ const oldService = await factory({ key: kek });
652
+ await oldService.set('rotate-cred', { important: 'data' }, 'user-r');
653
+ const rotatedService = await factory({
654
+ key: newKEK,
655
+ keyVersion: 2,
656
+ previousKey: kek,
657
+ });
658
+ const before = await rotatedService.get('rotate-cred', 'user-r');
659
+ assert.deepEqual(before, { important: 'data' });
660
+ assert.ok(rotatedService.rotateKEK);
661
+ const count = await rotatedService.rotateKEK();
662
+ assert.ok(count > 0);
663
+ const newOnlyService = await factory({
664
+ key: newKEK,
665
+ keyVersion: 2,
666
+ });
667
+ const after = await newOnlyService.get('rotate-cred', 'user-r');
668
+ assert.deepEqual(after, { important: 'data' });
669
+ });
670
+ test('rotateKEK throws without previousKey', async () => {
671
+ const service = await factory({ key: kek });
672
+ assert.ok(service.rotateKEK);
673
+ await assert.rejects(() => service.rotateKEK(), {
674
+ message: 'No previousKey configured — nothing to rotate from',
675
+ });
676
+ });
677
+ });
678
+ }
573
679
  if (services.agentRunService) {
574
680
  const factory = services.agentRunService;
575
681
  describe(`AgentRunService [${name}]`, () => {
@@ -23,6 +23,7 @@ import type { AIRunStateService } from '../services/ai-run-state-service.js';
23
23
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
24
24
  import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.js';
25
25
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
26
+ import type { CredentialService } from '../services/credential-service.js';
26
27
  export type PikkuWiringTypes = 'http' | 'scheduler' | 'trigger' | 'channel' | 'rpc' | 'queue' | 'mcp' | 'cli' | 'workflow' | 'agent' | 'gateway';
27
28
  export interface FunctionServicesMeta {
28
29
  optimized: boolean;
@@ -172,6 +173,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
172
173
  agentRunService?: AgentRunService;
173
174
  /** Workflow run service (listing workflow runs) */
174
175
  workflowRunService?: WorkflowRunService;
176
+ /** Credential service for dynamic/managed credentials (OAuth tokens, per-user API keys) */
177
+ credentialService?: CredentialService;
175
178
  }
176
179
  /**
177
180
  * Represents different forms of wire within Pikku and the outside world.
@@ -200,6 +203,10 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
200
203
  getSession: () => Promise<UserSession> | UserSession | undefined;
201
204
  /** Whether the session was modified during this run */
202
205
  hasSessionChanged: () => boolean;
206
+ /** Set a credential value (available in middleware) */
207
+ setCredential: (name: string, value: unknown) => void;
208
+ /** Get all resolved credentials (only available in pikkuWireServices) */
209
+ getCredentials: () => Record<string, unknown>;
203
210
  }>;
204
211
  /**
205
212
  * A function that can wrap an wire and be called before or after
@@ -4,11 +4,95 @@ import { runWorkflowGraph } from '../workflow/graph/graph-runner.js';
4
4
  import { ContextAwareRPCService, resolveNamespace } from '../rpc/rpc-runner.js';
5
5
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js';
6
6
  import { validateWorkflowWiring, computeEntryNodeIds, } from '../workflow/graph/graph-validation.js';
7
+ function formatSchemaType(schema, depth = 0) {
8
+ if (!schema)
9
+ return 'any';
10
+ if (schema.enum)
11
+ return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
12
+ if (schema.type === 'array') {
13
+ const itemType = schema.items
14
+ ? formatSchemaType(schema.items, depth + 1)
15
+ : 'any';
16
+ return `${itemType}[]`;
17
+ }
18
+ if (schema.type === 'object' && schema.properties) {
19
+ if (depth > 1)
20
+ return 'object';
21
+ const fields = Object.entries(schema.properties)
22
+ .map(([k, v]) => `${k}: ${formatSchemaType(v, depth + 1)}`)
23
+ .join(', ');
24
+ return `{${fields}}`;
25
+ }
26
+ return schema.type || 'any';
27
+ }
28
+ function resolveToolMetaForWorkflow(toolName) {
29
+ const resolved = toolName.includes(':') ? resolveNamespace(toolName) : null;
30
+ let fnMeta;
31
+ let schemas;
32
+ if (resolved) {
33
+ fnMeta = pikkuState(resolved.package, 'function', 'meta')[resolved.function];
34
+ schemas = pikkuState(resolved.package, 'misc', 'schemas');
35
+ }
36
+ else {
37
+ const rpcMeta = pikkuState(null, 'rpc', 'meta');
38
+ const pikkuFuncId = rpcMeta[toolName];
39
+ if (!pikkuFuncId)
40
+ return null;
41
+ fnMeta = pikkuState(null, 'function', 'meta')[pikkuFuncId];
42
+ schemas = pikkuState(null, 'misc', 'schemas');
43
+ }
44
+ if (!fnMeta)
45
+ return null;
46
+ const inputSchema = fnMeta.inputSchemaName
47
+ ? schemas.get(fnMeta.inputSchemaName)
48
+ : null;
49
+ const outputSchema = fnMeta.outputSchemaName
50
+ ? schemas.get(fnMeta.outputSchemaName)
51
+ : null;
52
+ return { fnMeta, inputSchema, outputSchema, schemas };
53
+ }
54
+ function buildDetailedToolSchemas(toolNames) {
55
+ const lines = [];
56
+ for (const toolName of toolNames) {
57
+ const meta = resolveToolMetaForWorkflow(toolName);
58
+ if (!meta)
59
+ continue;
60
+ const { inputSchema, outputSchema } = meta;
61
+ const inputProps = inputSchema?.properties
62
+ ? Object.entries(inputSchema.properties)
63
+ .map(([k, v]) => `${k}${inputSchema.required?.includes(k) ? '' : '?'}: ${formatSchemaType(v)}`)
64
+ .join(', ')
65
+ : '';
66
+ const outputPaths = outputSchema ? collectOutputPaths(outputSchema) : [];
67
+ lines.push(`**${toolName}**\n` +
68
+ ` input: {${inputProps}}\n` +
69
+ (outputPaths.length > 0
70
+ ? ` output paths: ${outputPaths.join(', ')}`
71
+ : ` output: void`));
72
+ }
73
+ return lines.join('\n');
74
+ }
75
+ function collectOutputPaths(schema, prefix = '') {
76
+ if (!schema?.properties)
77
+ return [];
78
+ const paths = [];
79
+ for (const [key, prop] of Object.entries(schema.properties)) {
80
+ const path = prefix ? `${prefix}.${key}` : key;
81
+ const type = prop.type || 'any';
82
+ if (prop.type === 'object' && prop.properties) {
83
+ paths.push(...collectOutputPaths(prop, path));
84
+ }
85
+ else {
86
+ paths.push(`${path}: ${type}`);
87
+ }
88
+ }
89
+ return paths;
90
+ }
7
91
  export function buildDynamicWorkflowInstructions(tools, mode) {
8
92
  if (mode === 'read') {
9
93
  return ('\n\n## Existing Workflows\n\n' +
10
94
  'You can list and execute previously saved workflows using listAgentWorkflows and executeAgentWorkflow.\n' +
11
- 'Use listAgentWorkflows to discover what\'s available, then executeAgentWorkflow to run them.');
95
+ "Use listAgentWorkflows to discover what's available, then executeAgentWorkflow to run them.");
12
96
  }
13
97
  const modeGuidance = mode === 'always'
14
98
  ? 'When a user requests a complex multi-step task, prefer creating a workflow over making sequential tool calls.\n' +
@@ -19,66 +103,94 @@ export function buildDynamicWorkflowInstructions(tools, mode) {
19
103
  'can run in background) and wait for confirmation before creating.\n' +
20
104
  'Do NOT create workflows automatically — always propose and get user approval first.\n' +
21
105
  'For one-off tasks, just use the tools directly.\n\n';
22
- const toolSchemaLines = [];
106
+ const toolSummaryLines = [];
23
107
  for (const toolName of tools) {
24
- let fnMeta;
25
- let schemas;
26
- const resolved = toolName.includes(':') ? resolveNamespace(toolName) : null;
27
- if (resolved) {
28
- fnMeta = pikkuState(resolved.package, 'function', 'meta')[resolved.function];
29
- schemas = pikkuState(resolved.package, 'misc', 'schemas');
30
- }
31
- else {
32
- const rpcMeta = pikkuState(null, 'rpc', 'meta');
33
- const pikkuFuncId = rpcMeta[toolName];
34
- if (!pikkuFuncId)
35
- continue;
36
- fnMeta = pikkuState(null, 'function', 'meta')[pikkuFuncId];
37
- schemas = pikkuState(null, 'misc', 'schemas');
38
- }
39
- if (!fnMeta)
108
+ const meta = resolveToolMetaForWorkflow(toolName);
109
+ if (!meta)
40
110
  continue;
41
- const inputSchema = fnMeta.inputSchemaName
42
- ? schemas.get(fnMeta.inputSchemaName)
43
- : null;
44
- const outputSchema = fnMeta.outputSchemaName
45
- ? schemas.get(fnMeta.outputSchemaName)
46
- : null;
47
- const toolDescription = fnMeta.description || fnMeta.title || '';
48
- const inputProps = inputSchema?.properties
49
- ? Object.entries(inputSchema.properties)
50
- .map(([k, v]) => `${k}${inputSchema.required?.includes(k) ? '' : '?'}: ${v.type || 'any'}`)
51
- .join(', ')
111
+ const { fnMeta, inputSchema, outputSchema } = meta;
112
+ const desc = fnMeta.description || fnMeta.title || '';
113
+ const inputFields = inputSchema?.properties
114
+ ? Object.keys(inputSchema.properties).join(', ')
52
115
  : '';
53
- const outputProps = outputSchema?.properties
54
- ? Object.entries(outputSchema.properties)
55
- .map(([k, v]) => `${k}: ${v.type || 'any'}`)
56
- .join(', ')
57
- : 'any';
58
- toolSchemaLines.push(`- ${toolName}(input: {${inputProps}}) → {${outputProps}}${toolDescription ? ` — ${toolDescription}` : ''}`);
116
+ const outputFields = outputSchema?.properties
117
+ ? Object.keys(outputSchema.properties).join(', ')
118
+ : '';
119
+ toolSummaryLines.push(`| \`${toolName}\` | ${desc || '-'} | ${inputFields || '-'} | ${outputFields || '-'} |`);
59
120
  }
60
- return ('\n\n## Workflow Creation\n\n' +
61
- modeGuidance +
62
- 'You can create workflows that chain your tools together. Use createAgentWorkflow to validate and preview a workflow, then saveAgentWorkflow to save it, then executeAgentWorkflow to run it.\n\n' +
63
- '### Tool Schemas:\n' +
64
- toolSchemaLines.join('\n') +
65
- '\n\n### Workflow Format:\n' +
66
- '- Each node has: rpcName (tool name), input (with $ref to wire outputs), next (flow control), onError\n' +
67
- '- Use {"$ref": "nodeId", "path": "fieldName"} to wire a previous node\'s output field to this node\'s input\n' +
68
- '- Use {"$ref": "trigger", "path": "fieldName"} to extract a field from the workflow\'s trigger input. IMPORTANT: Always include "path" — never pass {"$ref": "trigger"} without "path", or the entire trigger object will be used as the value.\n' +
69
- '- Use {"$ref": "nodeId", "path": "fieldName"} to extract a field from a previous node\'s output. Always include "path" to select the specific field.\n' +
70
- '- next can be a string (single next node), array (parallel), or object {branchKey: nextNode} for branching\n' +
71
- '\n### Example (add todo from trigger title, then list):\n' +
72
- '{"add":{"rpcName":"todos:addTodo","input":{"title":{"$ref":"trigger","path":"title"}},"next":"list"},"list":{"rpcName":"todos:listTodos","input":{}}}\n' +
73
- '\n### Example (add todo, sleep, complete using addTodo result id):\n' +
74
- '{"add":{"rpcName":"todos:addTodo","input":{"title":{"$ref":"trigger","path":"title"}},"next":"wait"},"wait":{"rpcName":"sleep","input":{"seconds":5},"next":"complete"},"complete":{"rpcName":"todos:completeTodo","input":{"id":{"$ref":"add","path":"id"}}}}');
121
+ const sections = [
122
+ '\n\n## Workflow Creation\n',
123
+ modeGuidance,
124
+ '### When to Create a Workflow\n' +
125
+ 'Create a workflow when:\n' +
126
+ '- The task has 2+ steps with data dependencies between them\n' +
127
+ '- The task will be repeated (workflows are reusable)\n' +
128
+ '- Steps can fail independently and need error handling\n' +
129
+ '- Steps can run in parallel for better performance\n' +
130
+ 'Do NOT create a workflow for single tool calls just call the tool directly.\n',
131
+ '### Process\n' +
132
+ '1. Check if a suitable workflow exists: `listAgentWorkflows`\n' +
133
+ '2. If the request is too vague to build a reliable workflow, ask the user to be more specific. For example, if a user says "make a workflow that does stuff with my data" — ask them what steps they need and what data flows between them.\n' +
134
+ '3. Plan the graph: identify steps, dependencies, parallel paths, and failure modes\n' +
135
+ '4. Create and validate: `createAgentWorkflow` — if validation fails, fix the errors and retry\n' +
136
+ '5. Present the workflow to the user and get approval\n' +
137
+ '6. Activate: `saveAgentWorkflow`\n' +
138
+ '7. Run: `executeAgentWorkflow`\n',
139
+ '### Available Tools\n' +
140
+ '| Tool | Description | Input fields | Output fields |\n' +
141
+ '|------|-------------|-------------|---------------|\n' +
142
+ toolSummaryLines.join('\n') +
143
+ '\n\n' +
144
+ 'Full schemas (input types, output paths) are returned by `createAgentWorkflow` after validation.\n',
145
+ '### Graph Format Reference\n\n' +
146
+ '**Node:** `{ rpcName, input?, next?, onError? }`\n\n' +
147
+ '**Data wiring (input field values):**\n' +
148
+ '- `{"$ref": "trigger", "path": "fieldName"}` — extract field from trigger input (ALWAYS include `path`)\n' +
149
+ '- `{"$ref": "nodeId", "path": "fieldName"}` — extract field from a previous node\'s output (ALWAYS include `path`)\n' +
150
+ '- `path` supports dot notation for nested fields — use the "output paths" listed above to find the correct path\n' +
151
+ '- Example: if a tool\'s output paths show `todo.id: string`, use `{"$ref": "nodeId", "path": "todo.id"}`\n' +
152
+ '- `{"$template": {"parts": ["Hello ", "!"], "expressions": [{"$ref": "trigger", "path": "name"}]}}` — string interpolation\n' +
153
+ '- Static values passed directly: `{"count": 10}`\n\n' +
154
+ '**Flow control (`next`):**\n' +
155
+ '- `"next": "b"` — sequential\n' +
156
+ '- `"next": ["b", "c"]` — parallel fan-out\n' +
157
+ '- `"next": {"ok": "b", "fail": "c"}` — conditional branch (node calls `graph.branch("ok")` at runtime)\n' +
158
+ '- Omit `next` for terminal nodes. Entry nodes are auto-detected.\n\n' +
159
+ '**Error handling:**\n' +
160
+ '- `"onError": "handler"` — route to error handler on failure\n' +
161
+ '- Error handler receives `{ error: { message: string } }` as input\n' +
162
+ '- Without `onError`, a node failure fails the entire workflow\n\n' +
163
+ '**Sub-workflows:** if `rpcName` matches an existing workflow name, it runs as a child workflow with its own execution context.\n\n' +
164
+ '**Trigger input:** the workflow receives input when executed. Entry nodes extract fields via `$ref: "trigger"`. Document what fields your workflow expects in the `description`.\n',
165
+ '### Design Principles\n' +
166
+ '1. **Map dependencies first** — before writing nodes, identify which steps depend on which outputs\n' +
167
+ '2. **Parallelize independent work** — steps with no data dependency should fan out via array `next`\n' +
168
+ '3. **Add error handlers for external calls** — any node calling an external service should have `onError`\n' +
169
+ "4. **Minimize blast radius** — isolate risky nodes so their failure doesn't cascade\n" +
170
+ '5. **Name nodes by intent** — `fetchUsers`, `sendAlert` not `step1`, `step2`\n' +
171
+ '6. **Keep graphs shallow** — prefer wide parallel graphs over deep sequential chains when possible\n',
172
+ '### Common Mistakes\n' +
173
+ '- `{"$ref": "trigger"}` without `path` — passes entire object, use `{"$ref": "trigger", "path": "field"}`\n' +
174
+ '- Wrong path depth: if output paths show `todo.id`, use `"path": "todo.id"` not `"path": "id"`\n' +
175
+ '- Type mismatch: wiring a string output to a number input\n' +
176
+ '- Dangling `onError`/`next` references to nodes not in the graph\n' +
177
+ '- Cycles: if A→B→A, there are no entry nodes\n',
178
+ '### Examples\n\n' +
179
+ '**Sequential with nested output path (if addTodo output paths show `todo.id`):**\n' +
180
+ '```json\n{"add":{"rpcName":"addTodo","input":{"title":{"$ref":"trigger","path":"title"}},"next":"complete"},"complete":{"rpcName":"completeTodo","input":{"id":{"$ref":"add","path":"todo.id"}}}}\n```\n\n' +
181
+ '**Error handling:**\n' +
182
+ '```json\n{"deploy":{"rpcName":"deploy","input":{"env":{"$ref":"trigger","path":"env"}},"next":"notify","onError":"rollback"},"notify":{"rpcName":"alert","input":{"message":"Deployed"}},"rollback":{"rpcName":"rollback","input":{},"next":"alertFail"},"alertFail":{"rpcName":"alert","input":{"message":"Rolled back"}}}\n```\n\n' +
183
+ '**Parallel fan-out:**\n' +
184
+ '```json\n{"fetchA":{"rpcName":"getA","input":{},"next":"combine"},"fetchB":{"rpcName":"getB","input":{},"next":"combine"},"combine":{"rpcName":"graph:merge","input":{"items":[{"$ref":"fetchA","path":"data"},{"$ref":"fetchB","path":"data"}]}}}\n```',
185
+ ];
186
+ return sections.join('\n');
75
187
  }
76
188
  export function buildWorkflowTools(agentName, packageName, toolNames, mode, streamContext, sessionService) {
77
189
  const tools = [];
78
190
  if (mode !== 'read') {
79
191
  tools.push({
80
192
  name: 'createAgentWorkflow',
81
- description: 'Validate and create a draft workflow graph that chains tools together. Call saveAgentWorkflow to activate it after the user approves.',
193
+ description: 'Validate and create a draft workflow DAG that chains tools together. The graph is validated for wiring correctness and type compatibility. Call saveAgentWorkflow to activate it after the user approves.',
82
194
  inputSchema: {
83
195
  type: 'object',
84
196
  properties: {
@@ -92,7 +204,7 @@ export function buildWorkflowTools(agentName, packageName, toolNames, mode, stre
92
204
  },
93
205
  nodes: {
94
206
  type: 'string',
95
- description: 'JSON string of a map of nodeId to node config. Each node has: rpcName (tool name), input (with $ref to wire outputs), next (string|string[]|{branchKey: string}), onError (string|string[]). Example: {"list":{"rpcName":"todos:listTodos","input":{}}}',
207
+ description: 'JSON string of nodeIdnode map. Node fields: rpcName (tool name), input (use $ref+path to wire data), next (flow control), onError (error routing). Must have 2+ nodes.',
96
208
  },
97
209
  },
98
210
  required: ['name', 'nodes'],
@@ -117,11 +229,18 @@ export function buildWorkflowTools(agentName, packageName, toolNames, mode, stre
117
229
  error: 'A workflow must have at least 2 nodes. A single node is just a tool call — use the tool directly instead.',
118
230
  };
119
231
  }
232
+ const usedTools = [
233
+ ...new Set(Object.values(nodes)
234
+ .map((n) => n.rpcName)
235
+ .filter(Boolean)),
236
+ ];
237
+ const toolSchemas = buildDetailedToolSchemas(usedTools);
120
238
  const validationErrors = validateWorkflowWiring(nodes, toolNames);
121
239
  if (validationErrors.length > 0) {
122
240
  return {
123
241
  error: 'Workflow validation failed',
124
242
  errors: validationErrors,
243
+ toolSchemas,
125
244
  };
126
245
  }
127
246
  const entryNodeIds = computeEntryNodeIds(nodes);
@@ -152,7 +271,8 @@ export function buildWorkflowTools(agentName, packageName, toolNames, mode, stre
152
271
  graphHash,
153
272
  entryNodes: entryNodeIds,
154
273
  nodeCount: Object.keys(nodes).length,
155
- message: `Workflow '${fullName}' validated and saved as draft. Present this to the user and call saveAgentWorkflow to activate it.`,
274
+ toolSchemas,
275
+ message: `Workflow '${fullName}' validated and saved as draft. Check the toolSchemas to verify your $ref paths match the output paths. Then present this to the user and call saveAgentWorkflow to activate it.`,
156
276
  };
157
277
  },
158
278
  });
@@ -1,4 +1,5 @@
1
1
  import { PikkuError } from '../../errors/error-handler.js';
2
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
2
3
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
3
4
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js';
4
5
  import { randomUUID } from 'crypto';
@@ -354,7 +355,7 @@ export async function prepareAgentRun(agentName, input, params, agentSessionMap,
354
355
  const { agent, packageName, resolvedName } = resolveAgent(agentName);
355
356
  const agentRunner = singletonServices.aiAgentRunner;
356
357
  if (!agentRunner) {
357
- throw new Error('AIAgentRunnerService not available in singletonServices');
358
+ throw new AIProviderNotConfiguredError();
358
359
  }
359
360
  if (agent.dynamicWorkflows && singletonServices.workflowService) {
360
361
  const persisted = await singletonServices.workflowService.getAIGeneratedWorkflows(resolvedName);
@@ -3,6 +3,7 @@ import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, } from
3
3
  import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js';
4
4
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
5
5
  import { resolveModelConfig } from './ai-agent-model-config.js';
6
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
6
7
  import { randomUUID } from 'crypto';
7
8
  export async function runAIAgent(agentName, input, params, agentSessionMap) {
8
9
  const sessionMap = agentSessionMap ?? new Map();
@@ -262,7 +263,7 @@ export async function resumeAIAgentSync(runId, approvals, params, expectedAgentN
262
263
  const memoryConfig = agent.memory;
263
264
  const agentRunner = singletonServices.aiAgentRunner;
264
265
  if (!agentRunner) {
265
- throw new Error('AIAgentRunnerService not available');
266
+ throw new AIProviderNotConfiguredError();
266
267
  }
267
268
  const approvedIds = new Set(approvals.filter((a) => a.approved).map((a) => a.toolCallId));
268
269
  const rejectedIds = new Set(approvals.filter((a) => !a.approved).map((a) => a.toolCallId));
@@ -1,4 +1,5 @@
1
1
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
2
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
2
3
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
3
4
  import { randomUUID } from 'crypto';
4
5
  import { parseWorkingMemory, deepMergeWorkingMemory, resolveMemoryServices, loadContextMessages, trimMessages, } from './ai-agent-memory.js';
@@ -471,7 +472,7 @@ export async function resumeAIAgent(input, channel, params, options) {
471
472
  const memoryConfig = agent.memory;
472
473
  const agentRunner = singletonServices.aiAgentRunner;
473
474
  if (!agentRunner) {
474
- throw new Error('AIAgentRunnerService not available in singletonServices');
475
+ throw new AIProviderNotConfiguredError();
475
476
  }
476
477
  if (!input.approved) {
477
478
  if (pending.type === 'agent-call') {
@@ -3,6 +3,8 @@ import { createHTTPWire } from '../../http/http-runner.js';
3
3
  import { closeWireServices } from '../../../utils.js';
4
4
  import { processMessageHandlers } from '../channel-handler.js';
5
5
  import { PikkuLocalChannelHandler } from './local-channel-handler.js';
6
+ import { isProduction } from '../../../env.js';
7
+ import { getErrorResponse } from '../../../errors/error-handler.js';
6
8
  import { handleHTTPError } from '../../../handle-error.js';
7
9
  import { PikkuSessionService, createMiddlewareSessionWireProps, } from '../../../services/user-session-service.js';
8
10
  import { runChannelLifecycleWithMiddleware } from '../channel-common.js';
@@ -75,9 +77,10 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
75
77
  }
76
78
  catch (e) {
77
79
  singletonServices.logger.error(`Error handling onConnect: ${e}`);
80
+ const errorResponse = getErrorResponse(e);
78
81
  channel.send({
79
- error: e.message || 'Unknown error',
80
- errorName: e.constructor?.name,
82
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
83
+ ...(!isProduction() && { errorName: e.constructor?.name }),
81
84
  });
82
85
  }
83
86
  }
@@ -97,9 +100,10 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
97
100
  }
98
101
  catch (e) {
99
102
  singletonServices.logger.error(`Error handling onDisconnect: ${e}`);
103
+ const errorResponse = getErrorResponse(e);
100
104
  channel.send({
101
- error: e.message || 'Unknown error',
102
- errorName: e.constructor?.name,
105
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
106
+ ...(!isProduction() && { errorName: e.constructor?.name }),
103
107
  });
104
108
  }
105
109
  }
@@ -113,9 +117,10 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
113
117
  }
114
118
  catch (e) {
115
119
  singletonServices.logger.error(e);
120
+ const errorResponse = getErrorResponse(e);
116
121
  channel.send({
117
- error: e.message || 'Unknown error',
118
- errorName: e.constructor?.name,
122
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
123
+ ...(!isProduction() && { errorName: e.constructor?.name }),
119
124
  });
120
125
  setTimeout(() => channel.close(), 200);
121
126
  }
@@ -0,0 +1,24 @@
1
+ import type { OAuth2CredentialConfig } from '../secret/secret.types.js';
2
+ export type CoreCredential<T = unknown> = {
3
+ name: string;
4
+ displayName: string;
5
+ description?: string;
6
+ type: 'singleton' | 'wire';
7
+ schema: T;
8
+ oauth2?: OAuth2CredentialConfig & {
9
+ appCredentialSecretId: string;
10
+ };
11
+ };
12
+ export type CredentialDefinitionMeta = {
13
+ name: string;
14
+ displayName: string;
15
+ description?: string;
16
+ type: 'singleton' | 'wire';
17
+ schema?: Record<string, unknown> | string;
18
+ oauth2?: OAuth2CredentialConfig & {
19
+ appCredentialSecretId: string;
20
+ };
21
+ sourceFile?: string;
22
+ };
23
+ export type CredentialDefinitionsMeta = Record<string, CredentialDefinitionMeta>;
24
+ export type CredentialDefinitions = CredentialDefinitionMeta[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export { wireCredential } from './wire-credential.js';
2
+ export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js';
3
+ export type { CoreCredential, CredentialDefinitionMeta, CredentialDefinitionsMeta, CredentialDefinitions, } from './credential.types.js';
@@ -0,0 +1,2 @@
1
+ export { wireCredential } from './wire-credential.js';
2
+ export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js';
@@ -0,0 +1,6 @@
1
+ import type { CredentialDefinitions, CredentialDefinitionsMeta } from './credential.types.js';
2
+ export interface SchemaRefLike {
3
+ variableName: string;
4
+ sourceFile: string;
5
+ }
6
+ export declare function validateAndBuildCredentialDefinitionsMeta(definitions: CredentialDefinitions, schemaLookup: Map<string, SchemaRefLike>): CredentialDefinitionsMeta;