@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.
- package/CHANGELOG.md +31 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.js +1 -0
- package/dist/errors/errors.d.ts +14 -0
- package/dist/errors/errors.js +27 -0
- package/dist/function/function-runner.d.ts +3 -1
- package/dist/function/function-runner.js +5 -1
- package/dist/handle-error.js +2 -1
- package/dist/middleware/auth-bearer.js +10 -1
- package/dist/middleware/auth-cookie.js +34 -25
- package/dist/middleware/timeout.js +11 -5
- package/dist/pikku-state.js +1 -1
- package/dist/services/credential-service.d.ts +40 -0
- package/dist/services/credential-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +13 -0
- package/dist/services/credential-wire-service.js +31 -0
- package/dist/services/index.d.ts +5 -0
- package/dist/services/index.js +3 -0
- package/dist/services/local-content.d.ts +7 -4
- package/dist/services/local-content.js +42 -12
- package/dist/services/local-credential-service.d.ts +10 -0
- package/dist/services/local-credential-service.js +33 -0
- package/dist/services/local-secrets.js +2 -2
- package/dist/services/typed-credential-service.d.ts +27 -0
- package/dist/services/typed-credential-service.js +40 -0
- package/dist/testing/service-tests.d.ts +8 -0
- package/dist/testing/service-tests.js +107 -1
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
- package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
- package/dist/wirings/channel/local/local-channel-runner.js +11 -6
- package/dist/wirings/credential/credential.types.d.ts +24 -0
- package/dist/wirings/credential/credential.types.js +1 -0
- package/dist/wirings/credential/index.d.ts +3 -0
- package/dist/wirings/credential/index.js +2 -0
- package/dist/wirings/credential/validate-credential-definitions.d.ts +6 -0
- package/dist/wirings/credential/validate-credential-definitions.js +38 -0
- package/dist/wirings/credential/wire-credential.d.ts +48 -0
- package/dist/wirings/credential/wire-credential.js +47 -0
- package/dist/wirings/http/http-runner.js +8 -2
- package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
- package/dist/wirings/oauth2/index.d.ts +2 -1
- package/dist/wirings/oauth2/index.js +1 -1
- package/dist/wirings/oauth2/oauth2-client.js +4 -8
- package/dist/wirings/oauth2/oauth2-routes.d.ts +35 -0
- package/dist/wirings/oauth2/oauth2-routes.js +146 -0
- package/dist/wirings/oauth2/oauth2.types.d.ts +0 -26
- package/dist/wirings/workflow/graph/graph-runner.d.ts +8 -1
- package/dist/wirings/workflow/graph/graph-runner.js +87 -7
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +5 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +99 -24
- package/dist/wirings/workflow/workflow.types.d.ts +4 -0
- package/package.json +2 -1
- package/src/env.ts +1 -0
- package/src/errors/errors.ts +35 -0
- package/src/function/function-runner.ts +11 -0
- package/src/handle-error.ts +2 -1
- package/src/middleware/auth-bearer.ts +10 -1
- package/src/middleware/auth-cookie.ts +11 -4
- package/src/middleware/timeout.ts +14 -7
- package/src/pikku-state.ts +1 -1
- package/src/services/credential-service.ts +44 -0
- package/src/services/credential-wire-service.ts +44 -0
- package/src/services/index.ts +12 -0
- package/src/services/local-content.ts +61 -13
- package/src/services/local-credential-service.ts +41 -0
- package/src/services/local-secrets.test.ts +2 -2
- package/src/services/local-secrets.ts +2 -2
- package/src/services/typed-credential-service.ts +75 -0
- package/src/testing/service-tests.ts +140 -1
- package/src/types/core.types.ts +7 -0
- package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
- package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
- package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
- package/src/wirings/channel/local/local-channel-runner.ts +11 -6
- package/src/wirings/credential/credential.types.ts +28 -0
- package/src/wirings/credential/index.ts +8 -0
- package/src/wirings/credential/validate-credential-definitions.ts +64 -0
- package/src/wirings/credential/wire-credential.ts +49 -0
- package/src/wirings/http/http-runner.ts +11 -2
- package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
- package/src/wirings/oauth2/index.ts +2 -1
- package/src/wirings/oauth2/oauth2-client.ts +4 -8
- package/src/wirings/oauth2/oauth2-routes.ts +234 -0
- package/src/wirings/oauth2/oauth2.types.ts +0 -27
- package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
- package/src/wirings/workflow/graph/graph-runner.ts +117 -8
- package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +150 -34
- package/src/wirings/workflow/workflow.types.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +0 -20
- package/dist/wirings/oauth2/wire-oauth2-credential.js +0 -21
- 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: '
|
|
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
|
-
|
|
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
|
|
106
|
+
const toolSummaryLines = [];
|
|
23
107
|
for (const toolName of tools) {
|
|
24
|
-
|
|
25
|
-
|
|
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 =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
54
|
-
? Object.
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'###
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
'
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
207
|
+
description: 'JSON string of nodeId→node 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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,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;
|