@pikku/core 0.12.51 → 0.12.53
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 +74 -0
- package/dist/services/http-scenario-actors.d.ts +67 -0
- package/dist/services/http-scenario-actors.js +193 -0
- package/dist/services/http-user-flow-actors.d.ts +20 -0
- package/dist/services/http-user-flow-actors.js +100 -0
- package/dist/services/index.d.ts +5 -2
- package/dist/services/index.js +4 -1
- package/dist/services/istanbul-coverage-service.d.ts +12 -0
- package/dist/services/istanbul-coverage-service.js +41 -0
- package/dist/services/meta-service.d.ts +4 -4
- package/dist/services/meta-service.js +8 -8
- package/dist/services/scenario-actors-service.d.ts +21 -0
- package/dist/services/scenario-actors-service.js +1 -0
- package/dist/services/stub-tracker.d.ts +43 -0
- package/dist/services/stub-tracker.js +146 -0
- package/dist/services/user-flow-actors-service.d.ts +11 -1
- package/dist/services/v8-coverage-service.d.ts +41 -0
- package/dist/services/v8-coverage-service.js +63 -0
- package/dist/types/core.types.d.ts +13 -4
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +68 -0
- package/dist/wirings/actor-flow/actor-flow.types.js +1 -0
- package/dist/wirings/actor-flow/index.d.ts +11 -0
- package/dist/wirings/actor-flow/index.js +1 -0
- package/dist/wirings/actor-flow/run-conversation.d.ts +36 -0
- package/dist/wirings/actor-flow/run-conversation.js +181 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +22 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +4 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +92 -2
- package/dist/wirings/workflow/workflow.types.d.ts +7 -7
- package/package.json +2 -1
- package/src/services/http-scenario-actors-converse.test.ts +222 -0
- package/src/services/{http-user-flow-actors.test.ts → http-scenario-actors.test.ts} +17 -7
- package/src/services/http-scenario-actors.ts +269 -0
- package/src/services/index.ts +27 -8
- package/src/services/istanbul-coverage-service.test.ts +66 -0
- package/src/services/istanbul-coverage-service.ts +65 -0
- package/src/services/meta-service.ts +9 -9
- package/src/services/scenario-actors-service.ts +27 -0
- package/src/services/stub-tracker.test.ts +104 -0
- package/src/services/stub-tracker.ts +185 -0
- package/src/services/v8-coverage-service.test.ts +69 -0
- package/src/services/v8-coverage-service.ts +121 -0
- package/src/types/core.types.ts +13 -4
- package/src/wirings/actor-flow/actor-flow.types.ts +73 -0
- package/src/wirings/actor-flow/index.ts +22 -0
- package/src/wirings/actor-flow/run-conversation.test.ts +176 -0
- package/src/wirings/actor-flow/run-conversation.ts +285 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +35 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +144 -5
- package/src/wirings/workflow/{user-flow-step.test.ts → scenario-step.test.ts} +19 -14
- package/src/wirings/workflow/workflow.types.ts +8 -6
- package/tsconfig.tsbuildinfo +1 -1
- package/src/services/http-user-flow-actors.ts +0 -132
- package/src/services/user-flow-actors-service.ts +0 -31
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/** One turn the persona takes: the message to send and whether it's finished. */
|
|
2
|
+
const PERSONA_TURN_SCHEMA = {
|
|
3
|
+
type: 'object',
|
|
4
|
+
properties: {
|
|
5
|
+
message: { type: 'string' },
|
|
6
|
+
done: { type: 'boolean' },
|
|
7
|
+
},
|
|
8
|
+
required: ['message', 'done'],
|
|
9
|
+
};
|
|
10
|
+
/** The persona's approve/deny decision for each pending tool request. */
|
|
11
|
+
const APPROVAL_DECISION_SCHEMA = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
decisions: {
|
|
15
|
+
type: 'array',
|
|
16
|
+
items: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
toolCallId: { type: 'string' },
|
|
20
|
+
approved: { type: 'boolean' },
|
|
21
|
+
reason: { type: 'string' },
|
|
22
|
+
},
|
|
23
|
+
required: ['toolCallId', 'approved'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
required: ['decisions'],
|
|
28
|
+
};
|
|
29
|
+
/** The persona's final verdict on whether the task was accomplished. */
|
|
30
|
+
const EVALUATION_SCHEMA = {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
passed: { type: 'boolean' },
|
|
34
|
+
reasoning: { type: 'string' },
|
|
35
|
+
},
|
|
36
|
+
required: ['passed', 'reasoning'],
|
|
37
|
+
};
|
|
38
|
+
const DEFAULT_MAX_TURNS = 12;
|
|
39
|
+
const DEFAULT_MAX_APPROVAL_ROUNDS = 16;
|
|
40
|
+
function msg(role, content) {
|
|
41
|
+
return {
|
|
42
|
+
id: globalThis.crypto.randomUUID(),
|
|
43
|
+
role,
|
|
44
|
+
content,
|
|
45
|
+
createdAt: new Date(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Read a structured `run` result, falling back to parsing JSON from text. */
|
|
49
|
+
function readObject(result) {
|
|
50
|
+
if (result.object && typeof result.object === 'object') {
|
|
51
|
+
return result.object;
|
|
52
|
+
}
|
|
53
|
+
if (result.text) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(result.text);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function personaInstructions(persona, task) {
|
|
64
|
+
return [
|
|
65
|
+
`You are role-playing a real user interacting with an AI assistant. Stay in character at all times — you are the user, not the assistant.`,
|
|
66
|
+
persona.name ? `Your name is ${persona.name}.` : '',
|
|
67
|
+
persona.jobTitle ? `Your role: ${persona.jobTitle}.` : '',
|
|
68
|
+
persona.personality
|
|
69
|
+
? `Your personality and communication style: ${persona.personality}. Match this tone, vocabulary, and level of detail exactly.`
|
|
70
|
+
: '',
|
|
71
|
+
`Your goal in this conversation: ${task}.`,
|
|
72
|
+
`Send one message at a time. Set "done" to true only once your goal is clearly accomplished, or clearly impossible.`,
|
|
73
|
+
]
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join('\n');
|
|
76
|
+
}
|
|
77
|
+
/** Route the target agent's pending tool approvals through the persona. */
|
|
78
|
+
async function decideApprovals(params, instructions, pending) {
|
|
79
|
+
const policy = params.approvals ?? 'in-persona';
|
|
80
|
+
if (policy === 'always') {
|
|
81
|
+
return pending.map((p) => ({ toolCallId: p.toolCallId, approved: true }));
|
|
82
|
+
}
|
|
83
|
+
if (policy === 'never') {
|
|
84
|
+
return pending.map((p) => ({ toolCallId: p.toolCallId, approved: false }));
|
|
85
|
+
}
|
|
86
|
+
const summary = pending
|
|
87
|
+
.map((p) => `- toolCallId "${p.toolCallId}": ${p.toolName}(${JSON.stringify(p.args)})${p.reason ? ` — ${p.reason}` : ''}`)
|
|
88
|
+
.join('\n');
|
|
89
|
+
const result = await params.llm({
|
|
90
|
+
model: params.model,
|
|
91
|
+
instructions: `${instructions}\nThe assistant is asking permission to run tools on your behalf. Decide whether YOU, as this persona, would allow each one.`,
|
|
92
|
+
messages: [
|
|
93
|
+
msg('user', `The assistant wants to run these tools:\n${summary}\nApprove or deny each toolCallId.`),
|
|
94
|
+
],
|
|
95
|
+
tools: [],
|
|
96
|
+
maxSteps: 1,
|
|
97
|
+
toolChoice: 'none',
|
|
98
|
+
outputSchema: APPROVAL_DECISION_SCHEMA,
|
|
99
|
+
});
|
|
100
|
+
const decided = readObject(result)?.decisions ?? [];
|
|
101
|
+
// Every pending call must get a decision; a missing one defaults to denied.
|
|
102
|
+
return pending.map((p) => {
|
|
103
|
+
const match = decided.find((d) => d.toolCallId === p.toolCallId);
|
|
104
|
+
return { toolCallId: p.toolCallId, approved: match?.approved ?? false };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** Drive the target to a non-suspended reply, routing approvals to the persona. */
|
|
108
|
+
async function converseWithTarget(params, instructions, message) {
|
|
109
|
+
const maxRounds = params.maxApprovalRounds ?? DEFAULT_MAX_APPROVAL_ROUNDS;
|
|
110
|
+
let reply = await params.target.run(message);
|
|
111
|
+
let rounds = 0;
|
|
112
|
+
while (reply.status === 'suspended' &&
|
|
113
|
+
reply.pendingApprovals &&
|
|
114
|
+
reply.pendingApprovals.length > 0) {
|
|
115
|
+
if (rounds >= maxRounds) {
|
|
116
|
+
throw new Error(`Target agent "${params.agentName}" stayed suspended after ${maxRounds} approval rounds (runId ${reply.runId}); aborting to avoid an unbounded approve loop.`);
|
|
117
|
+
}
|
|
118
|
+
rounds++;
|
|
119
|
+
const decisions = await decideApprovals(params, instructions, reply.pendingApprovals);
|
|
120
|
+
reply = await params.target.approve(reply.runId, decisions);
|
|
121
|
+
}
|
|
122
|
+
return reply;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Run a conversation: an LLM-driven persona holds a real multi-turn exchange
|
|
126
|
+
* with a target agent (driven via the injected transport), answers the target's
|
|
127
|
+
* tool-approval requests in-persona, then evaluates whether the task was met.
|
|
128
|
+
* Deterministic checks are the caller's responsibility.
|
|
129
|
+
*/
|
|
130
|
+
export async function runConversation(params) {
|
|
131
|
+
const maxTurns = params.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
132
|
+
const instructions = personaInstructions(params.persona, params.task);
|
|
133
|
+
// Seed a kickoff so the very first persona turn has a non-empty message list
|
|
134
|
+
// (providers reject an empty prompt). It's an instruction TO the persona, so
|
|
135
|
+
// it never appears in the transcript.
|
|
136
|
+
const personaMessages = [
|
|
137
|
+
msg('user', 'Begin the conversation now — send your first message to the assistant to work towards your goal.'),
|
|
138
|
+
];
|
|
139
|
+
const transcript = [];
|
|
140
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
141
|
+
const personaResult = await params.llm({
|
|
142
|
+
model: params.model,
|
|
143
|
+
instructions,
|
|
144
|
+
messages: personaMessages,
|
|
145
|
+
tools: [],
|
|
146
|
+
maxSteps: 1,
|
|
147
|
+
toolChoice: 'none',
|
|
148
|
+
outputSchema: PERSONA_TURN_SCHEMA,
|
|
149
|
+
});
|
|
150
|
+
const turnData = readObject(personaResult);
|
|
151
|
+
const personaMessage = turnData?.message?.trim();
|
|
152
|
+
if (!personaMessage) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
personaMessages.push(msg('assistant', personaMessage));
|
|
156
|
+
transcript.push(`${params.personaName}: ${personaMessage}`);
|
|
157
|
+
const reply = await converseWithTarget(params, instructions, personaMessage);
|
|
158
|
+
personaMessages.push(msg('user', reply.text ?? ''));
|
|
159
|
+
transcript.push(`${params.agentName}: ${reply.text ?? ''}`);
|
|
160
|
+
if (turnData?.done) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const evalResult = await params.llm({
|
|
165
|
+
model: params.model,
|
|
166
|
+
instructions: `${instructions}\nThe conversation has ended. Judge honestly whether your goal was accomplished.`,
|
|
167
|
+
messages: [
|
|
168
|
+
msg('user', `Here is the full conversation:\n\n${transcript.join('\n')}\n\nSuccess criterion: ${params.evaluate}\n\nWas it met?`),
|
|
169
|
+
],
|
|
170
|
+
tools: [],
|
|
171
|
+
maxSteps: 1,
|
|
172
|
+
toolChoice: 'none',
|
|
173
|
+
outputSchema: EVALUATION_SCHEMA,
|
|
174
|
+
});
|
|
175
|
+
const verdict = readObject(evalResult);
|
|
176
|
+
return {
|
|
177
|
+
passed: verdict?.passed ?? false,
|
|
178
|
+
reasoning: verdict?.reasoning ?? 'No evaluation produced.',
|
|
179
|
+
transcript,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* These types define the step-based workflow format extracted by the inspector
|
|
4
4
|
*/
|
|
5
5
|
import type { WorkflowRun } from '../workflow.types.js';
|
|
6
|
-
import type {
|
|
6
|
+
import type { ScenarioActor } from '../../../services/scenario-actors-service.js';
|
|
7
7
|
/**
|
|
8
8
|
* Workflow step options
|
|
9
9
|
*/
|
|
@@ -15,16 +15,16 @@ export interface WorkflowStepOptions {
|
|
|
15
15
|
/** Delay between retry attempts (e.g., '1s', '2s', '2min') */
|
|
16
16
|
retryDelay?: string | number;
|
|
17
17
|
/**
|
|
18
|
-
* Run this step as an actor (
|
|
18
|
+
* Run this step as an actor (scenarios). The RPC is sent through the
|
|
19
19
|
* actor's authenticated client over the REAL transport — never dispatched
|
|
20
20
|
* internally — so auth middleware and permissions are exercised end-to-end.
|
|
21
21
|
* The step is recorded durably like any RPC step.
|
|
22
22
|
*/
|
|
23
|
-
actor?:
|
|
23
|
+
actor?: ScenarioActor;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Options for workflow.expectEventually() — a durable polling step used by
|
|
27
|
-
*
|
|
27
|
+
* scenarios to await async effects (a notification landing, a job finishing).
|
|
28
28
|
*/
|
|
29
29
|
export interface WorkflowExpectEventuallyOptions extends WorkflowStepOptions {
|
|
30
30
|
/** Give up after this long (e.g. '30s'). Default '30s'. */
|
|
@@ -32,6 +32,18 @@ export interface WorkflowExpectEventuallyOptions extends WorkflowStepOptions {
|
|
|
32
32
|
/** Poll interval (e.g. '1s'). Default '1s'. */
|
|
33
33
|
interval?: string | number;
|
|
34
34
|
}
|
|
35
|
+
/** Options for workflow.expectError() */
|
|
36
|
+
export interface WorkflowExpectErrorOptions extends WorkflowStepOptions {
|
|
37
|
+
/** Assert the error message matches (string = substring match). */
|
|
38
|
+
matches?: string | RegExp;
|
|
39
|
+
}
|
|
40
|
+
/** Options for workflow.expectService() */
|
|
41
|
+
export interface WorkflowExpectServiceOptions extends WorkflowStepOptions {
|
|
42
|
+
/** Assert a recorded call's first argument deep-equals this value. */
|
|
43
|
+
calledWith?: unknown;
|
|
44
|
+
/** Assert the exact number of matching calls. Default: at least one. */
|
|
45
|
+
times?: number;
|
|
46
|
+
}
|
|
35
47
|
/**
|
|
36
48
|
* Type signature for workflow.do() RPC form - used by inspector
|
|
37
49
|
*/
|
|
@@ -110,7 +122,7 @@ export interface RpcStepMeta {
|
|
|
110
122
|
inputs?: Record<string, InputSource> | 'passthrough';
|
|
111
123
|
/** Step options */
|
|
112
124
|
options?: WorkflowStepOptions;
|
|
113
|
-
/**
|
|
125
|
+
/** Scenario actor name this step runs as ({ actor: actors.x }) */
|
|
114
126
|
actor?: string;
|
|
115
127
|
/** True for workflow.expectEventually polling steps */
|
|
116
128
|
expectEventually?: boolean;
|
|
@@ -340,10 +352,14 @@ export interface PikkuWorkflowWire {
|
|
|
340
352
|
/** Execute a workflow step (overloaded - RPC or inline form) */
|
|
341
353
|
do: WorkflowWireDoRPC & WorkflowWireDoInline;
|
|
342
354
|
/**
|
|
343
|
-
* Durable polling step (
|
|
355
|
+
* Durable polling step (scenarios): invoke `rpcName` (as an actor when
|
|
344
356
|
* `options.as` is set) until `predicate` passes or `options.within` elapses.
|
|
345
357
|
*/
|
|
346
358
|
expectEventually: <TOutput = any, TInput = any>(stepName: string, rpcName: string, data: TInput, predicate: (output: TOutput) => boolean, options?: WorkflowExpectEventuallyOptions) => Promise<TOutput>;
|
|
359
|
+
/** Error-path step (scenarios): succeeds only when the RPC throws; returns the message */
|
|
360
|
+
expectError: <TInput = any>(stepName: string, rpcName: string, data: TInput, options?: WorkflowExpectErrorOptions) => Promise<string>;
|
|
361
|
+
/** Stub-assertion step (scenarios): asserts `service.method` was called on the target server */
|
|
362
|
+
expectService: (stepName: string, serviceMethod: string, options?: WorkflowExpectServiceOptions) => Promise<void>;
|
|
347
363
|
/** Sleep for a duration */
|
|
348
364
|
sleep: WorkflowWireSleep;
|
|
349
365
|
/** Suspend workflow until explicitly resumed */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SerializedError } from '../../types/core.types.js';
|
|
2
2
|
import type { PikkuWorkflowWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
|
|
3
3
|
import type { WorkflowService } from '../../services/workflow-service.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { ScenarioActors } from '../../services/scenario-actors-service.js';
|
|
5
5
|
import { PikkuError } from '../../errors/error-handler.js';
|
|
6
6
|
import { type RunTimeline, type ReconstructedRunState } from './run-timeline.js';
|
|
7
7
|
import type { JobOptions } from '../queue/queue.types.js';
|
|
@@ -366,6 +366,8 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
366
366
|
* false to fall through to inline `setTimeout` behavior.
|
|
367
367
|
*/
|
|
368
368
|
protected scheduleSleep(runId: string, stepId: string, duration: number | string): Promise<boolean>;
|
|
369
|
+
/** Build HTTP scenario actors for a run started without them; undefined when SCENARIO_ACTOR_SECRET or the API URL is missing */
|
|
370
|
+
private resolveScenarioActors;
|
|
369
371
|
/**
|
|
370
372
|
* Start a new workflow run
|
|
371
373
|
* Automatically detects workflow type (DSL or graph) from meta and executes accordingly
|
|
@@ -375,7 +377,7 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
375
377
|
startWorkflow<I>(name: string, input: I, wire: WorkflowRunWire, rpcService: any, options?: {
|
|
376
378
|
inline?: boolean;
|
|
377
379
|
startNode?: string;
|
|
378
|
-
actors?:
|
|
380
|
+
actors?: ScenarioActors;
|
|
379
381
|
}): Promise<{
|
|
380
382
|
runId: string;
|
|
381
383
|
}>;
|
|
@@ -3,6 +3,7 @@ import { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSl
|
|
|
3
3
|
import { wireQueueWorker } from '../queue/queue-runner.js';
|
|
4
4
|
import { getSingletonServices, getCreateWireServices, pikkuState, } from '../../pikku-state.js';
|
|
5
5
|
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
6
|
+
import { createHttpScenarioActors } from '../../services/http-scenario-actors.js';
|
|
6
7
|
const resolveWorkflowMeta = (name) => {
|
|
7
8
|
const rootMeta = pikkuState(null, 'workflows', 'meta');
|
|
8
9
|
if (rootMeta[name]) {
|
|
@@ -601,6 +602,35 @@ export class PikkuWorkflowService {
|
|
|
601
602
|
await getSingletonServices().schedulerService.scheduleRPC(duration, this.getConfig().sleeperRPCName, { runId, stepId });
|
|
602
603
|
return true;
|
|
603
604
|
}
|
|
605
|
+
/** Build HTTP scenario actors for a run started without them; undefined when SCENARIO_ACTOR_SECRET or the API URL is missing */
|
|
606
|
+
async resolveScenarioActors() {
|
|
607
|
+
const services = getSingletonServices();
|
|
608
|
+
const variables = services?.variables;
|
|
609
|
+
const metaService = services?.metaService;
|
|
610
|
+
if (!variables || !metaService) {
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
const secret = await variables.get('SCENARIO_ACTOR_SECRET');
|
|
614
|
+
const apiUrl = await variables.get('API_URL');
|
|
615
|
+
if (!secret || !apiUrl) {
|
|
616
|
+
services?.logger?.warn('A scenario was started without actors but SCENARIO_ACTOR_SECRET / API_URL is not configured — running without actors.');
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
const actorsConfig = await metaService.getScenarioActorsMeta();
|
|
620
|
+
if (!actorsConfig || Object.keys(actorsConfig).length === 0) {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
const signInPath = (await variables.get('SCENARIO_SIGN_IN_PATH')) ??
|
|
624
|
+
'/api/auth/sign-in/actor';
|
|
625
|
+
const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc';
|
|
626
|
+
return createHttpScenarioActors({
|
|
627
|
+
apiUrl,
|
|
628
|
+
secret,
|
|
629
|
+
actors: actorsConfig,
|
|
630
|
+
signInPath,
|
|
631
|
+
rpcPath,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
604
634
|
/**
|
|
605
635
|
* Start a new workflow run
|
|
606
636
|
* Automatically detects workflow type (DSL or graph) from meta and executes accordingly
|
|
@@ -641,8 +671,12 @@ export class PikkuWorkflowService {
|
|
|
641
671
|
deterministic: workflowMeta.deterministic,
|
|
642
672
|
plannedSteps: workflowMeta.plannedSteps,
|
|
643
673
|
});
|
|
644
|
-
|
|
645
|
-
|
|
674
|
+
const actors = options?.actors ??
|
|
675
|
+
(workflowMeta.source === 'scenario'
|
|
676
|
+
? await this.resolveScenarioActors()
|
|
677
|
+
: undefined);
|
|
678
|
+
if (actors) {
|
|
679
|
+
this.runActors.set(runId, actors);
|
|
646
680
|
}
|
|
647
681
|
if (shouldInline) {
|
|
648
682
|
this.inlineRuns.add(runId);
|
|
@@ -1387,6 +1421,62 @@ export class PikkuWorkflowService {
|
|
|
1387
1421
|
}
|
|
1388
1422
|
}, options);
|
|
1389
1423
|
},
|
|
1424
|
+
expectError: async (stepName, rpcName, data, options) => {
|
|
1425
|
+
this.verifyStepName(stepName);
|
|
1426
|
+
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
1427
|
+
? `${addonNamespace}:${rpcName}`
|
|
1428
|
+
: rpcName;
|
|
1429
|
+
return await this.inlineStep(runId, stepName, async () => {
|
|
1430
|
+
let result;
|
|
1431
|
+
try {
|
|
1432
|
+
result = options?.actor
|
|
1433
|
+
? await options.actor.invoke(resolvedRpcName, data)
|
|
1434
|
+
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
1435
|
+
}
|
|
1436
|
+
catch (e) {
|
|
1437
|
+
const message = e?.message ?? String(e);
|
|
1438
|
+
if (options?.matches) {
|
|
1439
|
+
const matched = typeof options.matches === 'string'
|
|
1440
|
+
? message.includes(options.matches)
|
|
1441
|
+
: options.matches.test(message);
|
|
1442
|
+
if (!matched) {
|
|
1443
|
+
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return message;
|
|
1447
|
+
}
|
|
1448
|
+
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`);
|
|
1449
|
+
}, options);
|
|
1450
|
+
},
|
|
1451
|
+
expectService: async (stepName, serviceMethod, options) => {
|
|
1452
|
+
this.verifyStepName(stepName);
|
|
1453
|
+
const [service, method] = serviceMethod.split('.');
|
|
1454
|
+
if (!service || !method) {
|
|
1455
|
+
throw new Error(`[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`);
|
|
1456
|
+
}
|
|
1457
|
+
await this.inlineStep(runId, stepName, async () => {
|
|
1458
|
+
const rpcName = 'console:getStubCalls';
|
|
1459
|
+
const calls = options?.actor
|
|
1460
|
+
? await options.actor.invoke(rpcName, { service })
|
|
1461
|
+
: await rpcService.rpcWithWire(rpcName, { service }, {});
|
|
1462
|
+
const matching = (calls ?? []).filter((c) => c.service === service &&
|
|
1463
|
+
c.method === method &&
|
|
1464
|
+
(options?.calledWith === undefined ||
|
|
1465
|
+
JSON.stringify(c.args?.[0]) ===
|
|
1466
|
+
JSON.stringify(options.calledWith)));
|
|
1467
|
+
const expected = options?.times;
|
|
1468
|
+
const ok = expected === undefined
|
|
1469
|
+
? matching.length > 0
|
|
1470
|
+
: matching.length === expected;
|
|
1471
|
+
if (!ok) {
|
|
1472
|
+
const seen = (calls ?? [])
|
|
1473
|
+
.map((c) => `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`)
|
|
1474
|
+
.join('\n ') || '(none)';
|
|
1475
|
+
throw new Error(`[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
|
|
1476
|
+
`${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`);
|
|
1477
|
+
}
|
|
1478
|
+
}, options);
|
|
1479
|
+
},
|
|
1390
1480
|
// Implement workflow.sleep()
|
|
1391
1481
|
sleep: async (stepName, duration) => {
|
|
1392
1482
|
this.verifyStepName(stepName);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
|
|
2
2
|
import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
|
|
3
3
|
export type { WorkflowService } from '../../services/workflow-service.js';
|
|
4
|
-
export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
|
|
4
|
+
export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
|
|
5
5
|
import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
|
|
6
6
|
export interface WorkflowRunWire {
|
|
7
7
|
type: string;
|
|
@@ -236,9 +236,9 @@ export type WorkflowsMeta = Record<string, CommonWireMeta & {
|
|
|
236
236
|
context?: WorkflowContext;
|
|
237
237
|
dsl?: boolean;
|
|
238
238
|
expose?: boolean;
|
|
239
|
-
/** True for
|
|
240
|
-
|
|
241
|
-
/** Actor names a
|
|
239
|
+
/** True for pikkuScenario workflows (complex + actor steps). */
|
|
240
|
+
scenario?: boolean;
|
|
241
|
+
/** Actor names a scenario declares (personas it runs steps as). */
|
|
242
242
|
actors?: string[];
|
|
243
243
|
}>;
|
|
244
244
|
/**
|
|
@@ -251,13 +251,13 @@ export interface WorkflowRuntimeMeta {
|
|
|
251
251
|
name: string;
|
|
252
252
|
/** Pikku function name (for execution) */
|
|
253
253
|
pikkuFuncId: string;
|
|
254
|
-
/** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', '
|
|
255
|
-
source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow' | '
|
|
254
|
+
/** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', 'scenario' (complex + actor steps) */
|
|
255
|
+
source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow' | 'scenario';
|
|
256
256
|
/** Optional description */
|
|
257
257
|
description?: string;
|
|
258
258
|
/** Tags for organization */
|
|
259
259
|
tags?: string[];
|
|
260
|
-
/** Actor names a
|
|
260
|
+
/** Actor names a scenario declares (personas it runs steps as). */
|
|
261
261
|
actors?: string[];
|
|
262
262
|
/** Serialized nodes */
|
|
263
263
|
nodes?: Record<string, any>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.53",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"./workflow": "./dist/wirings/workflow/index.js",
|
|
25
25
|
"./workflow/timeline": "./dist/wirings/workflow/run-timeline.js",
|
|
26
26
|
"./workflow/types": "./dist/wirings/workflow/workflow.types.js",
|
|
27
|
+
"./actor-flow": "./dist/wirings/actor-flow/index.js",
|
|
27
28
|
"./channel/local": "./dist/wirings/channel/local/index.js",
|
|
28
29
|
"./channel/serverless": "./dist/wirings/channel/serverless/index.js",
|
|
29
30
|
"./http": "./dist/wirings/http/index.js",
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, test, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createServer, type Server } from 'node:http'
|
|
4
|
+
|
|
5
|
+
import { createHttpScenarioActors } from './http-scenario-actors.js'
|
|
6
|
+
import { pikkuState, resetPikkuState } from '../pikku-state.js'
|
|
7
|
+
import type { AIAgentStepResult } from './ai-agent-runner-service.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal target app exposing the agent HTTP surface: actor sign-in, the agent
|
|
11
|
+
* run route (suspends once for approval, then completes), and the batch approve
|
|
12
|
+
* route (records the decisions). `authRequired` toggles whether the agent
|
|
13
|
+
* routes reject unauthenticated calls with 401 (to exercise lazy sign-in).
|
|
14
|
+
*/
|
|
15
|
+
const startAgentTarget = async () => {
|
|
16
|
+
let agentRuns = 0
|
|
17
|
+
let logins = 0
|
|
18
|
+
let authRequired = false
|
|
19
|
+
let approvalsSeen: unknown[] = []
|
|
20
|
+
const server: Server = createServer((req, res) => {
|
|
21
|
+
const chunks: Buffer[] = []
|
|
22
|
+
req.on('data', (c) => chunks.push(c))
|
|
23
|
+
req.on('end', () => {
|
|
24
|
+
const body = chunks.length
|
|
25
|
+
? JSON.parse(Buffer.concat(chunks).toString())
|
|
26
|
+
: {}
|
|
27
|
+
const json = (obj: unknown) =>
|
|
28
|
+
res
|
|
29
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
30
|
+
.end(JSON.stringify(obj))
|
|
31
|
+
|
|
32
|
+
if (req.url === '/api/auth/sign-in/actor') {
|
|
33
|
+
logins++
|
|
34
|
+
res.setHeader('set-cookie', ['session=s1; Path=/; HttpOnly'])
|
|
35
|
+
json({ ok: true })
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isAgentRoute = req.url?.startsWith('/api/rpc/agent/')
|
|
40
|
+
if (
|
|
41
|
+
isAgentRoute &&
|
|
42
|
+
authRequired &&
|
|
43
|
+
!(req.headers.cookie ?? '').includes('session=')
|
|
44
|
+
) {
|
|
45
|
+
res.writeHead(401).end()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (req.url === '/api/rpc/agent/todoBot/approve') {
|
|
50
|
+
approvalsSeen.push(body.approvals)
|
|
51
|
+
json({ runId: body.runId, text: 'Created it.', status: 'completed' })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (req.url === '/api/rpc/agent/todoBot') {
|
|
55
|
+
agentRuns++
|
|
56
|
+
if (agentRuns === 1) {
|
|
57
|
+
json({
|
|
58
|
+
runId: 'run-1',
|
|
59
|
+
text: 'Let me do that.',
|
|
60
|
+
status: 'suspended',
|
|
61
|
+
pendingApprovals: [
|
|
62
|
+
{
|
|
63
|
+
toolCallId: 'tc1',
|
|
64
|
+
toolName: 'createTodo',
|
|
65
|
+
args: { title: 'x' },
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
json({
|
|
72
|
+
runId: `run-${agentRuns}`,
|
|
73
|
+
text: 'All set.',
|
|
74
|
+
status: 'completed',
|
|
75
|
+
})
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
res.writeHead(404).end()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
await new Promise<void>((resolve) => server.listen(0, resolve))
|
|
82
|
+
const { port } = server.address() as { port: number }
|
|
83
|
+
return {
|
|
84
|
+
server,
|
|
85
|
+
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
86
|
+
loginCount: () => logins,
|
|
87
|
+
approvalsSeen: () => approvalsSeen,
|
|
88
|
+
reset: (opts?: { authRequired?: boolean }) => {
|
|
89
|
+
agentRuns = 0
|
|
90
|
+
logins = 0
|
|
91
|
+
approvalsSeen = []
|
|
92
|
+
authRequired = opts?.authRequired ?? false
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Persona LLM scripted by the outputSchema it's asked for. */
|
|
98
|
+
const scriptedRunner = () => {
|
|
99
|
+
let turn = 0
|
|
100
|
+
const turns = [
|
|
101
|
+
{ message: 'please make a todo', done: false },
|
|
102
|
+
{ message: 'thanks', done: true },
|
|
103
|
+
]
|
|
104
|
+
return {
|
|
105
|
+
run: async (params: {
|
|
106
|
+
outputSchema?: unknown
|
|
107
|
+
}): Promise<AIAgentStepResult> => {
|
|
108
|
+
const props =
|
|
109
|
+
(params.outputSchema as { properties?: Record<string, unknown> })
|
|
110
|
+
?.properties ?? {}
|
|
111
|
+
const object =
|
|
112
|
+
'message' in props
|
|
113
|
+
? (turns[turn++] ?? turns[turns.length - 1])
|
|
114
|
+
: 'decisions' in props
|
|
115
|
+
? { decisions: [{ toolCallId: 'tc1', approved: true }] }
|
|
116
|
+
: { passed: true, reasoning: 'a todo was created' }
|
|
117
|
+
return {
|
|
118
|
+
text: '',
|
|
119
|
+
object,
|
|
120
|
+
toolCalls: [],
|
|
121
|
+
toolResults: [],
|
|
122
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
123
|
+
finishReason: 'stop',
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const wireRunner = () => {
|
|
130
|
+
resetPikkuState()
|
|
131
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
132
|
+
logger: {
|
|
133
|
+
info: () => {},
|
|
134
|
+
warn: () => {},
|
|
135
|
+
error: () => {},
|
|
136
|
+
debug: () => {},
|
|
137
|
+
},
|
|
138
|
+
aiAgentRunner: scriptedRunner(),
|
|
139
|
+
} as any)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('HttpScenarioActor.converse', async () => {
|
|
143
|
+
const target = await startAgentTarget()
|
|
144
|
+
after(() => {
|
|
145
|
+
target.server.close()
|
|
146
|
+
resetPikkuState()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('converses over HTTP, approves in-persona, returns a verdict', async () => {
|
|
150
|
+
wireRunner()
|
|
151
|
+
target.reset()
|
|
152
|
+
|
|
153
|
+
const actors = createHttpScenarioActors({
|
|
154
|
+
apiUrl: target.apiUrl,
|
|
155
|
+
secret: 'impersonation-secret',
|
|
156
|
+
model: 'test/test-model',
|
|
157
|
+
actors: {
|
|
158
|
+
pm: { email: 'pm@actors.local', name: 'Priya', personality: 'concise' },
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const verdict = await actors.pm!.converse({
|
|
163
|
+
agent: 'todoBot',
|
|
164
|
+
task: 'Get a todo created for the launch',
|
|
165
|
+
evaluate: 'A todo about the launch now exists',
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
assert.equal(verdict.passed, true)
|
|
169
|
+
assert.match(verdict.reasoning, /todo/i)
|
|
170
|
+
// The target's approval was answered in-persona (approved) over HTTP.
|
|
171
|
+
assert.deepEqual(target.approvalsSeen(), [
|
|
172
|
+
[{ toolCallId: 'tc1', approved: true }],
|
|
173
|
+
])
|
|
174
|
+
// No-auth agent → converse never signs in (lazy login).
|
|
175
|
+
assert.equal(target.loginCount(), 0)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('signs in lazily and retries once when an agent route returns 401', async () => {
|
|
179
|
+
wireRunner()
|
|
180
|
+
target.reset({ authRequired: true })
|
|
181
|
+
|
|
182
|
+
const actors = createHttpScenarioActors({
|
|
183
|
+
apiUrl: target.apiUrl,
|
|
184
|
+
secret: 'impersonation-secret',
|
|
185
|
+
model: 'test/test-model',
|
|
186
|
+
actors: { pm: { email: 'pm@actors.local' } },
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const verdict = await actors.pm!.converse({
|
|
190
|
+
agent: 'todoBot',
|
|
191
|
+
task: 'make a todo',
|
|
192
|
+
evaluate: 'a todo exists',
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
assert.equal(verdict.passed, true)
|
|
196
|
+
// 401 on the first (unauthenticated) call → one sign-in, then cached.
|
|
197
|
+
assert.equal(target.loginCount(), 1)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('throws when no AI provider is configured', async () => {
|
|
201
|
+
resetPikkuState()
|
|
202
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
203
|
+
logger: {
|
|
204
|
+
info: () => {},
|
|
205
|
+
warn: () => {},
|
|
206
|
+
error: () => {},
|
|
207
|
+
debug: () => {},
|
|
208
|
+
},
|
|
209
|
+
} as any)
|
|
210
|
+
|
|
211
|
+
const actors = createHttpScenarioActors({
|
|
212
|
+
apiUrl: target.apiUrl,
|
|
213
|
+
secret: 'impersonation-secret',
|
|
214
|
+
model: 'test/test-model',
|
|
215
|
+
actors: { pm: { email: 'pm@actors.local' } },
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
await assert.rejects(
|
|
219
|
+
actors.pm!.converse({ agent: 'todoBot', task: 't', evaluate: 'e' })
|
|
220
|
+
)
|
|
221
|
+
})
|
|
222
|
+
})
|