@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.
- package/CHANGELOG.md +93 -0
- package/dist/index.d.ts +1 -1
- 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 +2 -2
- package/dist/services/index.js +1 -1
- 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 +39 -0
- package/dist/services/scenario-actors-service.js +1 -0
- package/dist/services/user-flow-actors-service.d.ts +11 -1
- package/dist/types/core.types.d.ts +48 -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/http/http.types.d.ts +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +6 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +10 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +42 -2
- package/dist/wirings/workflow/workflow.types.d.ts +6 -6
- package/package.json +2 -1
- package/run-tests.sh +4 -4
- package/src/index.ts +6 -0
- 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 +8 -8
- package/src/services/meta-service.ts +9 -9
- package/src/services/{user-flow-actors-service.ts → scenario-actors-service.ts} +18 -4
- package/src/types/core.types.ts +53 -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/http/http.types.ts +1 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +6 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +50 -5
- package/src/wirings/workflow/{user-flow-step.test.ts → scenario-step.test.ts} +19 -14
- package/src/wirings/workflow/workflow.types.ts +6 -6
- package/tsconfig.tsbuildinfo +1 -1
- 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 {
|
|
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
|
|
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
|
|
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('
|
|
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
|
-
|
|
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', {
|
|
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(
|
|
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
|
+
}
|
package/src/services/index.ts
CHANGED
|
@@ -37,15 +37,15 @@ export type {
|
|
|
37
37
|
CopyFileArgs,
|
|
38
38
|
} from './content-service.js'
|
|
39
39
|
export type {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} from './
|
|
40
|
+
ScenarioActor,
|
|
41
|
+
ScenarioActorConfig,
|
|
42
|
+
ScenarioActors,
|
|
43
|
+
} from './scenario-actors-service.js'
|
|
44
44
|
export {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
type
|
|
48
|
-
} from './http-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
433
|
-
if (this.
|
|
432
|
+
async getScenarioActorsMeta(): Promise<Record<string, ScenarioActorConfig>> {
|
|
433
|
+
if (this.scenarioActorsMetaCache) return this.scenarioActorsMetaCache
|
|
434
434
|
|
|
435
|
-
const content = await this.readFile('workflow/
|
|
436
|
-
this.
|
|
437
|
-
return this.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
45
|
+
export type ScenarioActors = Record<string, ScenarioActor>
|