@pikku/core 0.12.50 → 0.12.52

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 (46) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/services/http-scenario-actors.d.ts +67 -0
  4. package/dist/services/http-scenario-actors.js +193 -0
  5. package/dist/services/http-user-flow-actors.d.ts +20 -0
  6. package/dist/services/http-user-flow-actors.js +100 -0
  7. package/dist/services/index.d.ts +2 -2
  8. package/dist/services/index.js +1 -1
  9. package/dist/services/meta-service.d.ts +4 -4
  10. package/dist/services/meta-service.js +8 -8
  11. package/dist/services/scenario-actors-service.d.ts +39 -0
  12. package/dist/services/scenario-actors-service.js +1 -0
  13. package/dist/services/user-flow-actors-service.d.ts +11 -1
  14. package/dist/types/core.types.d.ts +48 -4
  15. package/dist/wirings/actor-flow/actor-flow.types.d.ts +68 -0
  16. package/dist/wirings/actor-flow/actor-flow.types.js +1 -0
  17. package/dist/wirings/actor-flow/index.d.ts +11 -0
  18. package/dist/wirings/actor-flow/index.js +1 -0
  19. package/dist/wirings/actor-flow/run-conversation.d.ts +36 -0
  20. package/dist/wirings/actor-flow/run-conversation.js +181 -0
  21. package/dist/wirings/http/http.types.d.ts +1 -0
  22. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +6 -6
  23. package/dist/wirings/workflow/pikku-workflow-service.d.ts +10 -2
  24. package/dist/wirings/workflow/pikku-workflow-service.js +42 -2
  25. package/dist/wirings/workflow/workflow.types.d.ts +6 -6
  26. package/package.json +2 -1
  27. package/run-tests.sh +4 -4
  28. package/src/index.ts +6 -0
  29. package/src/services/http-scenario-actors-converse.test.ts +222 -0
  30. package/src/services/{http-user-flow-actors.test.ts → http-scenario-actors.test.ts} +17 -7
  31. package/src/services/http-scenario-actors.ts +269 -0
  32. package/src/services/index.ts +8 -8
  33. package/src/services/meta-service.ts +9 -9
  34. package/src/services/{user-flow-actors-service.ts → scenario-actors-service.ts} +18 -4
  35. package/src/types/core.types.ts +53 -4
  36. package/src/wirings/actor-flow/actor-flow.types.ts +73 -0
  37. package/src/wirings/actor-flow/index.ts +22 -0
  38. package/src/wirings/actor-flow/run-conversation.test.ts +176 -0
  39. package/src/wirings/actor-flow/run-conversation.ts +285 -0
  40. package/src/wirings/http/http.types.ts +1 -0
  41. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +6 -6
  42. package/src/wirings/workflow/pikku-workflow-service.ts +50 -5
  43. package/src/wirings/workflow/{user-flow-step.test.ts → scenario-step.test.ts} +19 -14
  44. package/src/wirings/workflow/workflow.types.ts +6 -6
  45. package/tsconfig.tsbuildinfo +1 -1
  46. package/src/services/http-user-flow-actors.ts +0 -132
@@ -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
+ })
@@ -2,7 +2,7 @@ import { describe, test, after } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { createServer, type Server } from 'node:http'
4
4
 
5
- import { createHttpUserFlowActors } from './http-user-flow-actors.js'
5
+ import { createHttpScenarioActors } from './http-scenario-actors.js'
6
6
 
7
7
  // Minimal target app: actor sign-in endpoint + exposed RPC endpoint, session
8
8
  // via cookie. Mirrors the Better Auth actor plugin's contract.
@@ -13,10 +13,14 @@ const startTarget = async () => {
13
13
  const chunks: Buffer[] = []
14
14
  req.on('data', (c) => chunks.push(c))
15
15
  req.on('end', () => {
16
- const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}
16
+ const body = chunks.length
17
+ ? JSON.parse(Buffer.concat(chunks).toString())
18
+ : {}
17
19
  if (req.url === '/api/auth/sign-in/actor') {
18
20
  if (body.secret !== 'impersonation-secret') {
19
- res.writeHead(401).end(JSON.stringify({ message: 'bad actor secret' }))
21
+ res
22
+ .writeHead(401)
23
+ .end(JSON.stringify({ message: 'bad actor secret' }))
20
24
  return
21
25
  }
22
26
  logins++
@@ -58,12 +62,12 @@ const startTarget = async () => {
58
62
  }
59
63
  }
60
64
 
61
- describe('HttpUserFlowActor', async () => {
65
+ describe('HttpScenarioActor', async () => {
62
66
  const target = await startTarget()
63
67
  after(() => target.server.close())
64
68
 
65
69
  const makeActors = (secret = 'impersonation-secret') =>
66
- createHttpUserFlowActors({
70
+ createHttpScenarioActors({
67
71
  apiUrl: target.apiUrl,
68
72
  secret,
69
73
  actors: {
@@ -82,14 +86,20 @@ describe('HttpUserFlowActor', async () => {
82
86
  test('logs in lazily once and replays the session cookie on RPCs', async () => {
83
87
  const actors = makeActors()
84
88
 
85
- const first = (await actors.customer!.invoke('createTodo', { title: 'x' })) as any
89
+ const first = (await actors.customer!.invoke('createTodo', {
90
+ title: 'x',
91
+ })) as any
86
92
  const second = (await actors.customer!.invoke('listTodos', {})) as any
87
93
 
88
94
  assert.equal(first.rpcName, 'createTodo')
89
95
  assert.deepEqual(first.echoed, { title: 'x' })
90
96
  assert.match(first.cookie, /session=s1/)
91
97
  assert.match(first.cookie, /csrf=c1/)
92
- assert.match(second.cookie, /session=s1/, 'session is cached, not re-minted')
98
+ assert.match(
99
+ second.cookie,
100
+ /session=s1/,
101
+ 'session is cached, not re-minted'
102
+ )
93
103
  assert.equal(target.loginCount(), 1)
94
104
  })
95
105
 
@@ -0,0 +1,269 @@
1
+ import type {
2
+ ScenarioActor,
3
+ ScenarioActorConfig,
4
+ ScenarioActors,
5
+ } from './scenario-actors-service.js'
6
+ import type {
7
+ ConverseOptions,
8
+ ActorFlowVerdict,
9
+ TargetAgentReply,
10
+ } from '../wirings/actor-flow/actor-flow.types.js'
11
+ import { runConversation } from '../wirings/actor-flow/run-conversation.js'
12
+ import { getSingletonServices } from '../pikku-state.js'
13
+ import { AIProviderNotConfiguredError } from '../errors/errors.js'
14
+
15
+ export interface HttpScenarioActorsConfig {
16
+ /**
17
+ * Base API URL of the target app, INCLUDING the HTTP prefix — e.g.
18
+ * `https://app.example.com/api` or `http://localhost:4000/api`. Actor
19
+ * sign-in is reached at `${apiUrl}${signInPath}` and exposed RPCs at
20
+ * `${apiUrl}${rpcPath}/:rpcName`.
21
+ */
22
+ apiUrl: string
23
+ /**
24
+ * The actor impersonation secret. Sign-in only ever works for user rows
25
+ * flagged `actor: true` — knowing the secret never impersonates real users.
26
+ */
27
+ secret: string
28
+ /** Actor name → config (usually from pikku.config.json's actor registry). */
29
+ actors: Record<string, ScenarioActorConfig>
30
+ /** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
31
+ signInPath?: string
32
+ /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
33
+ rpcPath?: string
34
+ /**
35
+ * Default model the persona uses when `actor.converse(...)` is called without
36
+ * an explicit `model`. The persona's own turns/approvals/evaluation run
37
+ * in-process via the configured `aiAgentRunner`.
38
+ */
39
+ model?: string
40
+ }
41
+
42
+ /**
43
+ * Default HTTP-backed actor. Signs in lazily on first invoke via the Better
44
+ * Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }` —
45
+ * the plugin upserts the actor-flagged user row and mints a session whose
46
+ * `actor` flag flows into audits/analytics). Holds the session cookies for
47
+ * its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
48
+ * outlive a session).
49
+ */
50
+ export class HttpScenarioActor implements ScenarioActor {
51
+ private cookie: string | null = null
52
+ private origin: string
53
+
54
+ constructor(
55
+ readonly name: string,
56
+ private actorConfig: ScenarioActorConfig,
57
+ private config: HttpScenarioActorsConfig
58
+ ) {
59
+ this.origin = new URL(config.apiUrl).origin
60
+ }
61
+
62
+ get email(): string {
63
+ return this.actorConfig.email
64
+ }
65
+
66
+ async invoke(rpcName: string, data: unknown): Promise<unknown> {
67
+ const cookie = this.cookie ?? (await this.login())
68
+ const res = await this.postRpc(rpcName, data, cookie)
69
+ if (res.status === 401) {
70
+ // Session expired mid-run — re-login once and retry.
71
+ this.cookie = null
72
+ return this.readRpcResponse(
73
+ rpcName,
74
+ await this.postRpc(rpcName, data, await this.login())
75
+ )
76
+ }
77
+ return this.readRpcResponse(rpcName, res)
78
+ }
79
+
80
+ async converse(options: ConverseOptions): Promise<ActorFlowVerdict> {
81
+ const { aiAgentRunner } = getSingletonServices()
82
+ if (!aiAgentRunner) {
83
+ throw new AIProviderNotConfiguredError()
84
+ }
85
+ const model = options.model ?? this.config.model
86
+ if (!model) {
87
+ throw new Error(
88
+ `[scenario] actor '${this.name}' converse needs a model — pass options.model or set 'model' on the actors service`
89
+ )
90
+ }
91
+ const threadId = globalThis.crypto.randomUUID()
92
+ const resourceId = `actor:${this.name}`
93
+
94
+ return runConversation({
95
+ persona: this.actorConfig,
96
+ personaName: this.actorConfig.name ?? this.name,
97
+ agentName: options.agent,
98
+ task: options.task,
99
+ evaluate: options.evaluate,
100
+ approvals: options.approvals,
101
+ model,
102
+ maxTurns: options.maxTurns,
103
+ llm: (params) => aiAgentRunner.run(params),
104
+ target: {
105
+ run: (message) =>
106
+ this.agentRun(options.agent, message, threadId, resourceId),
107
+ approve: (runId, decisions) =>
108
+ this.agentApprove(options.agent, runId, decisions),
109
+ },
110
+ })
111
+ }
112
+
113
+ /** Start/continue the target agent's run over HTTP as this actor. */
114
+ private async agentRun(
115
+ agentName: string,
116
+ message: string,
117
+ threadId: string,
118
+ resourceId: string
119
+ ): Promise<TargetAgentReply> {
120
+ const raw = await this.postAgent(`agent/${agentName}`, {
121
+ message,
122
+ threadId,
123
+ resourceId,
124
+ })
125
+ return normalizeAgentReply(raw)
126
+ }
127
+
128
+ /** Answer the target agent's pending approvals over HTTP and continue. */
129
+ private async agentApprove(
130
+ agentName: string,
131
+ runId: string,
132
+ decisions: { toolCallId: string; approved: boolean }[]
133
+ ): Promise<TargetAgentReply> {
134
+ const raw = await this.postAgent(`agent/${agentName}/approve`, {
135
+ runId,
136
+ approvals: decisions,
137
+ })
138
+ return normalizeAgentReply(raw)
139
+ }
140
+
141
+ /**
142
+ * POST an agent HTTP route (raw body, not RPC-wrapped). Signs in lazily: the
143
+ * first call goes out with whatever cookie we hold (none, for a no-auth
144
+ * agent), and only a 401 triggers `login()` + one retry. This lets an actor
145
+ * converse with a no-auth agent without any sign-in wiring, while still
146
+ * authenticating against agents that require a session.
147
+ */
148
+ private async postAgent(subPath: string, body: unknown): Promise<unknown> {
149
+ const rpcPath = this.config.rpcPath ?? '/rpc'
150
+ const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
151
+ const send = (cookie: string | null) =>
152
+ fetch(url, {
153
+ method: 'POST',
154
+ headers: {
155
+ 'content-type': 'application/json',
156
+ origin: this.origin,
157
+ ...(cookie ? { cookie } : {}),
158
+ },
159
+ body: JSON.stringify(body),
160
+ })
161
+
162
+ let res = await send(this.cookie)
163
+ if (res.status === 401) {
164
+ this.cookie = null
165
+ res = await send(await this.login())
166
+ }
167
+ if (!res.ok) {
168
+ const text = (await res.text().catch(() => '')).slice(0, 300)
169
+ throw new Error(
170
+ `[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`
171
+ )
172
+ }
173
+ if (res.status === 204) return undefined
174
+ const text = await res.text()
175
+ return text ? JSON.parse(text) : undefined
176
+ }
177
+
178
+ private async postRpc(rpcName: string, data: unknown, cookie: string) {
179
+ const rpcPath = this.config.rpcPath ?? '/rpc'
180
+ return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
181
+ method: 'POST',
182
+ headers: {
183
+ 'content-type': 'application/json',
184
+ origin: this.origin,
185
+ cookie,
186
+ },
187
+ body: JSON.stringify({ data }),
188
+ })
189
+ }
190
+
191
+ private async readRpcResponse(rpcName: string, res: Response) {
192
+ if (!res.ok) {
193
+ const body = (await res.text().catch(() => '')).slice(0, 300)
194
+ throw new Error(
195
+ `[scenario] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`
196
+ )
197
+ }
198
+ if (res.status === 204) return undefined
199
+ const text = await res.text()
200
+ return text ? JSON.parse(text) : undefined
201
+ }
202
+
203
+ private async login(): Promise<string> {
204
+ const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
205
+ const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
206
+ method: 'POST',
207
+ headers: { 'content-type': 'application/json', origin: this.origin },
208
+ body: JSON.stringify({
209
+ email: this.actorConfig.email,
210
+ name: this.actorConfig.name ?? this.name,
211
+ secret: this.config.secret,
212
+ }),
213
+ })
214
+ if (!res.ok) {
215
+ const body = (await res.text().catch(() => '')).slice(0, 300)
216
+ throw new Error(
217
+ `[scenario] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
218
+ )
219
+ }
220
+ const setCookies = res.headers.getSetCookie?.() ?? []
221
+ const cookie = setCookies
222
+ .map((c) => c.split(';')[0]!)
223
+ .filter(Boolean)
224
+ .join('; ')
225
+ if (!cookie) {
226
+ throw new Error(
227
+ `[scenario] actor sign-in for '${this.name}' returned no session cookie`
228
+ )
229
+ }
230
+ this.cookie = cookie
231
+ return cookie
232
+ }
233
+ }
234
+
235
+ /** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
236
+ function normalizeAgentReply(raw: unknown): TargetAgentReply {
237
+ const r = (raw ?? {}) as Record<string, unknown>
238
+ const pending = Array.isArray(r.pendingApprovals)
239
+ ? (r.pendingApprovals as Array<Record<string, unknown>>).map((p) => ({
240
+ toolCallId: String(p.toolCallId),
241
+ toolName: String(p.toolName),
242
+ args: p.args,
243
+ reason: typeof p.reason === 'string' ? p.reason : undefined,
244
+ }))
245
+ : undefined
246
+ return {
247
+ text: typeof r.text === 'string' ? r.text : '',
248
+ runId: typeof r.runId === 'string' ? r.runId : '',
249
+ status:
250
+ r.status === 'completed' || r.status === 'suspended'
251
+ ? r.status
252
+ : undefined,
253
+ pendingApprovals: pending,
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Build the injected `actors` service from the config registry: actor name →
259
+ * lazy HTTP actor. Wire the result as the `actors` singleton service.
260
+ */
261
+ export function createHttpScenarioActors(
262
+ config: HttpScenarioActorsConfig
263
+ ): ScenarioActors {
264
+ const actors: ScenarioActors = {}
265
+ for (const [name, actorConfig] of Object.entries(config.actors)) {
266
+ actors[name] = new HttpScenarioActor(name, actorConfig, config)
267
+ }
268
+ return actors
269
+ }
@@ -37,15 +37,15 @@ export type {
37
37
  CopyFileArgs,
38
38
  } from './content-service.js'
39
39
  export type {
40
- UserFlowActor,
41
- UserFlowActorConfig,
42
- UserFlowActors,
43
- } from './user-flow-actors-service.js'
40
+ ScenarioActor,
41
+ ScenarioActorConfig,
42
+ ScenarioActors,
43
+ } from './scenario-actors-service.js'
44
44
  export {
45
- HttpUserFlowActor,
46
- createHttpUserFlowActors,
47
- type HttpUserFlowActorsConfig,
48
- } from './http-user-flow-actors.js'
45
+ HttpScenarioActor,
46
+ createHttpScenarioActors,
47
+ type HttpScenarioActorsConfig,
48
+ } from './http-scenario-actors.js'
49
49
  export type { JWTService } from './jwt-service.js'
50
50
  export type {
51
51
  EmailService,
@@ -12,7 +12,7 @@ import type {
12
12
  MCPPromptMeta,
13
13
  } from '../wirings/mcp/mcp.types.js'
14
14
  import type { WorkflowsMeta } from '../wirings/workflow/workflow.types.js'
15
- import type { UserFlowActorConfig } from './user-flow-actors-service.js'
15
+ import type { ScenarioActorConfig } from './scenario-actors-service.js'
16
16
  import type {
17
17
  TriggerMeta,
18
18
  TriggerSourceMeta,
@@ -171,7 +171,7 @@ export interface MetaService {
171
171
  getGatewayMeta(): Promise<GatewaysMeta>
172
172
  getRpcMeta(): Promise<RPCMetaRecord>
173
173
  getWorkflowMeta(): Promise<WorkflowsMeta>
174
- getUserFlowActorsMeta(): Promise<Record<string, UserFlowActorConfig>>
174
+ getScenarioActorsMeta(): Promise<Record<string, ScenarioActorConfig>>
175
175
  getTriggerMeta(): Promise<TriggerMeta>
176
176
  getTriggerSourceMeta(): Promise<TriggerSourceMeta>
177
177
  getFunctionsMeta(): Promise<FunctionsMeta>
@@ -210,7 +210,7 @@ export class LocalMetaService implements MetaService {
210
210
  private gatewayMetaCache: GatewaysMeta | null = null
211
211
  private rpcMetaCache: RPCMetaRecord | null = null
212
212
  private workflowMetaCache: WorkflowsMeta | null = null
213
- private userFlowActorsMetaCache: Record<string, UserFlowActorConfig> | null =
213
+ private scenarioActorsMetaCache: Record<string, ScenarioActorConfig> | null =
214
214
  null
215
215
  private triggerMetaCache: TriggerMeta | null = null
216
216
  private triggerSourceMetaCache: TriggerSourceMeta | null = null
@@ -262,7 +262,7 @@ export class LocalMetaService implements MetaService {
262
262
  this.gatewayMetaCache = null
263
263
  this.rpcMetaCache = null
264
264
  this.workflowMetaCache = null
265
- this.userFlowActorsMetaCache = null
265
+ this.scenarioActorsMetaCache = null
266
266
  this.triggerMetaCache = null
267
267
  this.triggerSourceMetaCache = null
268
268
  this.functionsMetaCache = null
@@ -429,12 +429,12 @@ export class LocalMetaService implements MetaService {
429
429
  }
430
430
  }
431
431
 
432
- async getUserFlowActorsMeta(): Promise<Record<string, UserFlowActorConfig>> {
433
- if (this.userFlowActorsMetaCache) return this.userFlowActorsMetaCache
432
+ async getScenarioActorsMeta(): Promise<Record<string, ScenarioActorConfig>> {
433
+ if (this.scenarioActorsMetaCache) return this.scenarioActorsMetaCache
434
434
 
435
- const content = await this.readFile('workflow/user-flow-actors.gen.json')
436
- this.userFlowActorsMetaCache = content ? JSON.parse(content) : {}
437
- return this.userFlowActorsMetaCache!
435
+ const content = await this.readFile('workflow/scenario-actors.gen.json')
436
+ this.scenarioActorsMetaCache = content ? JSON.parse(content) : {}
437
+ return this.scenarioActorsMetaCache!
438
438
  }
439
439
 
440
440
  async getTriggerMeta(): Promise<TriggerMeta> {
@@ -1,18 +1,32 @@
1
+ import type {
2
+ ConverseOptions,
3
+ ActorFlowVerdict,
4
+ } from '../wirings/actor-flow/actor-flow.types.js'
5
+
1
6
  /**
2
- * A user-flow actor: a synthetic user (a normal user row flagged `actor`) that
7
+ * A scenario actor: a synthetic user (a normal user row flagged `actor`) that
3
8
  * workflow steps can run as. Passed to `workflow.do(step, rpc, data, { actor })`
4
9
  * — the step then goes through the actor's authenticated client over the REAL
5
10
  * transport (auth middleware, permissions, serialization all exercised),
6
11
  * never through internal dispatch. Login is lazy: the first `invoke` signs the
7
12
  * actor in and the session is cached for the actor's lifetime.
8
13
  */
9
- export interface UserFlowActor {
14
+ export interface ScenarioActor<TAgentName extends string = string> {
10
15
  /** Stable actor name (the key in pikku.config.json's actor registry). */
11
16
  readonly name: string
12
17
  /** The actor's user email — flows use it for invites/lookups. */
13
18
  readonly email: string
14
19
  /** Invoke an exposed RPC as this actor over the real transport. */
15
20
  invoke(rpcName: string, data: unknown): Promise<unknown>
21
+ /**
22
+ * Hold a dynamic conversation with a target Pikku AI agent, in THIS actor's
23
+ * persona (personality/jobTitle). Drives the target over the real transport
24
+ * as the signed-in actor, answers its tool-approval requests in-persona, and
25
+ * returns the actor's verdict on whether the task was met. Deterministic
26
+ * checks are the caller's job — use `invoke` afterwards. In a typed project
27
+ * `agent` is constrained to the generated union of agent names.
28
+ */
29
+ converse(options: ConverseOptions<TAgentName>): Promise<ActorFlowVerdict>
16
30
  }
17
31
 
18
32
  /**
@@ -20,7 +34,7 @@ export interface UserFlowActor {
20
34
  * identifies the actor's user row; personality/jobTitle exist for the console
21
35
  * screen and for agent-driven flows (the agent plays the persona).
22
36
  */
23
- export interface UserFlowActorConfig {
37
+ export interface ScenarioActorConfig {
24
38
  email: string
25
39
  name?: string
26
40
  jobTitle?: string
@@ -28,4 +42,4 @@ export interface UserFlowActorConfig {
28
42
  }
29
43
 
30
44
  /** The injected `actors` service: actor name → actor. */
31
- export type UserFlowActors = Record<string, UserFlowActor>
45
+ export type ScenarioActors = Record<string, ScenarioActor>