@pikku/core 0.12.67 → 0.12.70
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 +397 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- package/dist/services/in-memory-workflow-service.d.ts +2 -2
- package/dist/services/in-memory-workflow-service.js +2 -2
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/types/core.types.d.ts +21 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +18 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/queue/index.d.ts +1 -1
- package/dist/wirings/queue/queue.types.d.ts +30 -0
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- package/dist/wirings/workflow/index.d.ts +13 -2
- package/dist/wirings/workflow/index.js +15 -0
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +118 -12
- package/dist/wirings/workflow/pikku-workflow-service.js +166 -153
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +119 -2
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- package/src/services/in-memory-workflow-service.test.ts +50 -1
- package/src/services/in-memory-workflow-service.ts +3 -2
- package/src/services/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/types/core.types.ts +27 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +29 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/queue/index.ts +2 -0
- package/src/wirings/queue/queue.types.ts +32 -0
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- package/src/wirings/workflow/index.ts +74 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +126 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +306 -228
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +441 -8
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow.types.ts +137 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,14 +3,154 @@ import type {
|
|
|
3
3
|
ActorFlowVerdict,
|
|
4
4
|
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* What the transport answered, for a step that treats the status as data.
|
|
8
|
+
*
|
|
9
|
+
* An HTTP response with its body already drained: the stream can only be read
|
|
10
|
+
* once, and a step's return value crosses into the run record, so the response
|
|
11
|
+
* object itself cannot travel. This is the shape every caller ends up with.
|
|
12
|
+
*/
|
|
13
|
+
export interface ScenarioHttpResponse<T = unknown> {
|
|
14
|
+
status: number
|
|
15
|
+
ok: boolean
|
|
16
|
+
/**
|
|
17
|
+
* The parsed JSON body — or, when the body was not JSON, the raw text it was
|
|
18
|
+
* parsed from, so an HTML error page is still readable rather than lost.
|
|
19
|
+
* `undefined` for an empty response.
|
|
20
|
+
*
|
|
21
|
+
* `T` is a claim the caller makes, not one the transport checked: a step that
|
|
22
|
+
* knows the route's payload names it here instead of casting at every use.
|
|
23
|
+
*/
|
|
24
|
+
body: T
|
|
25
|
+
/**
|
|
26
|
+
* The whole body as text, so an assertion can search it without knowing the
|
|
27
|
+
* payload's shape — and so an error body that is HTML rather than JSON still
|
|
28
|
+
* says what went wrong.
|
|
29
|
+
*/
|
|
30
|
+
serialized: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drain a response into the shape a step can carry: the parsed body (an empty
|
|
35
|
+
* one counting as no body at all) alongside the text it was parsed from.
|
|
36
|
+
*
|
|
37
|
+
* `invokeRaw` returns this, and a step that has to reach past an actor — a
|
|
38
|
+
* route with no RPC, an identity no actor can hold — reaches for this rather
|
|
39
|
+
* than writing the same record by hand.
|
|
40
|
+
*/
|
|
41
|
+
export const readScenarioHttpResponse = async <T = unknown>(
|
|
42
|
+
res: Response
|
|
43
|
+
): Promise<ScenarioHttpResponse<T>> => {
|
|
44
|
+
const text = res.status === 204 ? '' : await res.text().catch(() => '')
|
|
45
|
+
return {
|
|
46
|
+
status: res.status,
|
|
47
|
+
ok: res.ok,
|
|
48
|
+
body: (text ? parseJsonBody(text) : undefined) as T,
|
|
49
|
+
serialized: text,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parseJsonBody = (text: string): unknown => {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text)
|
|
56
|
+
} catch {
|
|
57
|
+
return text
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How to send one JSON request, for `postScenarioJson`. */
|
|
62
|
+
export interface ScenarioJsonRequest {
|
|
63
|
+
/** Serialised as the JSON body. Omit for a request that carries none. */
|
|
64
|
+
body?: unknown
|
|
65
|
+
/** Sent alongside `content-type: application/json`, and may override it. */
|
|
66
|
+
headers?: Record<string, string>
|
|
67
|
+
/** Defaults to `POST` — the method every scenario route here answers. */
|
|
68
|
+
method?: string
|
|
69
|
+
/**
|
|
70
|
+
* The `fetch` to send it with. Pass a `ScenarioCookieJar`'s to keep the
|
|
71
|
+
* session; the global `fetch` otherwise, which is what a step asserting on a
|
|
72
|
+
* sessionless call wants.
|
|
73
|
+
*/
|
|
74
|
+
fetch?: typeof fetch
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* POST JSON somewhere and report what came back, without throwing on a 4xx/5xx.
|
|
79
|
+
*
|
|
80
|
+
* Every scenario that reaches past an actor was writing this by hand — the same
|
|
81
|
+
* `content-type`, the same `JSON.stringify`, the same drain — and the copies had
|
|
82
|
+
* drifted: some returned `res.json()`, which loses the status and throws
|
|
83
|
+
* outright when the target answers an empty body or an HTML error page. A
|
|
84
|
+
* refusal is the expected outcome of a permissions scenario, so it has to
|
|
85
|
+
* survive as data.
|
|
86
|
+
*/
|
|
87
|
+
export const postScenarioJson = async <T = unknown>(
|
|
88
|
+
url: string,
|
|
89
|
+
{
|
|
90
|
+
body,
|
|
91
|
+
headers,
|
|
92
|
+
method = 'POST',
|
|
93
|
+
fetch: send = fetch,
|
|
94
|
+
}: ScenarioJsonRequest = {}
|
|
95
|
+
): Promise<ScenarioHttpResponse<T>> =>
|
|
96
|
+
readScenarioHttpResponse<T>(
|
|
97
|
+
await send(url, {
|
|
98
|
+
method,
|
|
99
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
100
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
/** Per-call transport options. */
|
|
105
|
+
export interface ScenarioInvokeOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Headers to send alongside the actor's own session. This is how a step
|
|
108
|
+
* expresses an identity the actor registry cannot — an impersonation header,
|
|
109
|
+
* or one of the header-shim principals a credential scenario invents.
|
|
110
|
+
*/
|
|
111
|
+
headers?: Record<string, string>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The RPC surface an actor can reach, as name → input/output. A project binds
|
|
116
|
+
* its generated exposed RPC map here; the default leaves every name open, which
|
|
117
|
+
* is what an actor built by hand (or by a third-party driver) gets.
|
|
118
|
+
*/
|
|
119
|
+
export type ScenarioRpcMap = Record<string, { input: any; output: any }>
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The actor a step wire carries, for a project whose actor registry is known.
|
|
123
|
+
* An empty registry keeps the open actor type rather than collapsing to
|
|
124
|
+
* `never` — a project may still build actors itself.
|
|
125
|
+
*/
|
|
126
|
+
export type ScenarioActorOf<TActors> = [keyof TActors] extends [never]
|
|
127
|
+
? ScenarioActor
|
|
128
|
+
: TActors[keyof TActors]
|
|
129
|
+
|
|
6
130
|
/** A synthetic user (a user row flagged `actor`) that workflow steps run as over the real transport */
|
|
7
|
-
export interface ScenarioActor<
|
|
131
|
+
export interface ScenarioActor<
|
|
132
|
+
TAgentName extends string = string,
|
|
133
|
+
TRpcMap extends ScenarioRpcMap = ScenarioRpcMap,
|
|
134
|
+
> {
|
|
8
135
|
/** Stable actor name (the key in pikku.config.json's actor registry). */
|
|
9
136
|
readonly name: string
|
|
10
137
|
/** The actor's user email — flows use it for invites/lookups. */
|
|
11
138
|
readonly email: string
|
|
12
139
|
/** Invoke an exposed RPC as this actor over the real transport. */
|
|
13
|
-
invoke
|
|
140
|
+
invoke<TName extends keyof TRpcMap & string>(
|
|
141
|
+
rpcName: TName,
|
|
142
|
+
data: TRpcMap[TName]['input']
|
|
143
|
+
): Promise<TRpcMap[TName]['output']>
|
|
144
|
+
/**
|
|
145
|
+
* The same call, reporting what the transport answered rather than throwing.
|
|
146
|
+
* A refusal is the expected outcome of a permissions or scopes scenario, and
|
|
147
|
+
* `invoke`'s error truncates the body that names which scope was missing.
|
|
148
|
+
*/
|
|
149
|
+
invokeRaw<TName extends keyof TRpcMap & string>(
|
|
150
|
+
rpcName: TName,
|
|
151
|
+
data: TRpcMap[TName]['input'],
|
|
152
|
+
options?: ScenarioInvokeOptions
|
|
153
|
+
): Promise<ScenarioHttpResponse>
|
|
14
154
|
/** Converse with a Pikku AI agent in this actor's persona and return its verdict */
|
|
15
155
|
converse(options: ConverseOptions<TAgentName>): Promise<ActorFlowVerdict>
|
|
16
156
|
}
|
|
@@ -21,6 +161,21 @@ export interface ScenarioActorConfig {
|
|
|
21
161
|
name?: string
|
|
22
162
|
jobTitle?: string
|
|
23
163
|
personality?: string
|
|
164
|
+
/**
|
|
165
|
+
* The persona this body is one of — the KIND of person, declared in
|
|
166
|
+
* `scenarios.personas`. Most personas have exactly one actor and it is
|
|
167
|
+
* materialised for them; a second body of the same persona is what tenant
|
|
168
|
+
* isolation and peer-sharing scenarios are made of.
|
|
169
|
+
*/
|
|
170
|
+
persona?: string
|
|
171
|
+
/**
|
|
172
|
+
* Scopes this actor holds, granted directly rather than through a role, and
|
|
173
|
+
* the roles it belongs to. Pikku carries them; it never applies them — which
|
|
174
|
+
* scope store exists and which roles have been created is the app's own, so
|
|
175
|
+
* the app's seed reads these back off `scenarioActorConfigs` and grants them.
|
|
176
|
+
*/
|
|
177
|
+
scopes?: readonly string[]
|
|
178
|
+
roles?: readonly string[]
|
|
24
179
|
}
|
|
25
180
|
|
|
26
181
|
/** The injected `actors` service: actor name → actor. */
|
package/src/types/core.types.ts
CHANGED
|
@@ -22,6 +22,10 @@ import type {
|
|
|
22
22
|
WorkflowServiceConfig,
|
|
23
23
|
WorkflowStepWire,
|
|
24
24
|
} from '../wirings/workflow/workflow.types.js'
|
|
25
|
+
import type {
|
|
26
|
+
PikkuBrowserWire,
|
|
27
|
+
PikkuScenarioStepWire,
|
|
28
|
+
} from '../wirings/workflow/scenario-step.types.js'
|
|
25
29
|
import type { PikkuGraphWire } from '../wirings/workflow/graph/workflow-graph.types.js'
|
|
26
30
|
import type { PikkuTrigger } from '../wirings/trigger/trigger.types.js'
|
|
27
31
|
import type { PikkuGateway } from '../wirings/gateway/gateway.types.js'
|
|
@@ -30,7 +34,10 @@ import type { DeploymentService } from '../services/deployment-service.js'
|
|
|
30
34
|
import type { AIStorageService } from '../services/ai-storage-service.js'
|
|
31
35
|
|
|
32
36
|
import type { ContentService } from '../services/content-service.js'
|
|
33
|
-
import type {
|
|
37
|
+
import type {
|
|
38
|
+
ScenarioActorOf,
|
|
39
|
+
ScenarioActors,
|
|
40
|
+
} from '../services/scenario-actors-service.js'
|
|
34
41
|
import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js'
|
|
35
42
|
import type { AIEmbeddingService } from '../services/ai-embedding-service.js'
|
|
36
43
|
import type { AIRunStateService } from '../services/ai-run-state-service.js'
|
|
@@ -123,6 +130,15 @@ export type FunctionRuntimeMeta = {
|
|
|
123
130
|
scopes?: string[]
|
|
124
131
|
expose?: boolean
|
|
125
132
|
remote?: boolean
|
|
133
|
+
/**
|
|
134
|
+
* A step RPC: a name dispatched by a scenario run and refused everywhere
|
|
135
|
+
* else. It sits alongside `expose` (public) and `remote` as a kind of RPC
|
|
136
|
+
* rather than a separate concept — a step is invoked by name exactly as an
|
|
137
|
+
* RPC is, which is why a run records the step function in its `rpcName`.
|
|
138
|
+
* What makes it its own kind is that it is never network-callable: a step
|
|
139
|
+
* may drive a browser or assert against fixtures.
|
|
140
|
+
*/
|
|
141
|
+
scenarioStep?: boolean
|
|
126
142
|
mcp?: boolean
|
|
127
143
|
readonly?: boolean
|
|
128
144
|
deploy?: 'serverless' | 'server' | 'auto'
|
|
@@ -133,6 +149,10 @@ export type FunctionRuntimeMeta = {
|
|
|
133
149
|
workflowRetries?: number
|
|
134
150
|
/** Timeout when this function is used as a workflow step (e.g. '30s', '5m'). */
|
|
135
151
|
workflowTimeout?: string
|
|
152
|
+
/** Scenario steps only: this step drives a browser, so the runner must provision one and an actor is mandatory. */
|
|
153
|
+
scenarioStepBrowser?: boolean
|
|
154
|
+
/** Scenario steps only: the prose a reporter renders, with `{placeholders}` filled from the step's recorded input. */
|
|
155
|
+
scenarioStepTemplate?: string
|
|
136
156
|
version?: number
|
|
137
157
|
approvalRequired?: boolean
|
|
138
158
|
approvalDescription?: string
|
|
@@ -380,6 +400,7 @@ export type PikkuWire<
|
|
|
380
400
|
TypedWorkflow extends PikkuWorkflowWire | never = PikkuWorkflowWire,
|
|
381
401
|
TriggerOutput = unknown,
|
|
382
402
|
TypedScenario extends PikkuScenarioWire | never = PikkuScenarioWire,
|
|
403
|
+
TypedActors extends ScenarioActors = ScenarioActors,
|
|
383
404
|
> = {
|
|
384
405
|
/** Always present — lazily initialised on first access for every function invocation */
|
|
385
406
|
rpc: TypedRPC
|
|
@@ -402,7 +423,11 @@ export type PikkuWire<
|
|
|
402
423
|
cli: PikkuCLI
|
|
403
424
|
workflow: TypedWorkflow
|
|
404
425
|
scenario: TypedScenario
|
|
405
|
-
actors:
|
|
426
|
+
actors: TypedActors
|
|
427
|
+
/** Present on every scenario step invocation */
|
|
428
|
+
scenarioStep: PikkuScenarioStepWire<ScenarioActorOf<TypedActors>>
|
|
429
|
+
/** Present only when the runner provisioned a browser for this step */
|
|
430
|
+
browser: PikkuBrowserWire
|
|
406
431
|
workflowStep: WorkflowStepWire
|
|
407
432
|
graph: PikkuGraphWire
|
|
408
433
|
trigger: PikkuTrigger<TriggerOutput>
|
package/src/types/state.types.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
} from '../wirings/scheduler/scheduler.types.js'
|
|
41
41
|
import type {
|
|
42
42
|
CoreWorkflow,
|
|
43
|
+
CoreFeature,
|
|
43
44
|
WorkflowsRuntimeMeta,
|
|
44
45
|
} from '../wirings/workflow/workflow.types.js'
|
|
45
46
|
import type {
|
|
@@ -129,6 +130,8 @@ export interface PikkuPackageState {
|
|
|
129
130
|
}
|
|
130
131
|
workflows: {
|
|
131
132
|
registrations: Map<string, CoreWorkflow>
|
|
133
|
+
/** Scenario groups declared with `pikkuFeature`, keyed by export name. */
|
|
134
|
+
features: Map<string, CoreFeature>
|
|
132
135
|
meta: WorkflowsRuntimeMeta
|
|
133
136
|
}
|
|
134
137
|
trigger: {
|
|
@@ -21,7 +21,7 @@ export interface ConverseOptions<TAgentName extends string = string> {
|
|
|
21
21
|
evaluate: string
|
|
22
22
|
/** How the actor answers the agent's tool-approval requests. Default `'in-persona'`. */
|
|
23
23
|
approvals?: ActorFlowApprovalPolicy
|
|
24
|
-
/** Model the
|
|
24
|
+
/** Model the actor uses for its own turns/decisions. Falls back to the actor service default. */
|
|
25
25
|
model?: string
|
|
26
26
|
/** Hard cap on conversation turns before forcing evaluation. Default 12. */
|
|
27
27
|
maxTurns?: number
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
import { runConversation, type
|
|
4
|
+
import { runConversation, type ActorLLM } from './run-conversation.js'
|
|
5
5
|
import type {
|
|
6
6
|
TargetAgentDriver,
|
|
7
7
|
TargetAgentReply,
|
|
@@ -23,10 +23,10 @@ const scriptedLLM = (script: {
|
|
|
23
23
|
turns: Array<{ message: string; done: boolean }>
|
|
24
24
|
decisions: Array<{ toolCallId: string; approved: boolean }>
|
|
25
25
|
evaluation: { passed: boolean; reasoning: string }
|
|
26
|
-
}): { llm:
|
|
26
|
+
}): { llm: ActorLLM; calls: string[] } => {
|
|
27
27
|
let turn = 0
|
|
28
28
|
const calls: string[] = []
|
|
29
|
-
const llm:
|
|
29
|
+
const llm: ActorLLM = async (params) => {
|
|
30
30
|
const props =
|
|
31
31
|
(params.outputSchema as { properties?: Record<string, unknown> })
|
|
32
32
|
?.properties ?? {}
|
|
@@ -109,8 +109,8 @@ const alwaysSuspendingTarget = (): {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
const base = {
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
actor: { email: 'pm@example.com', name: 'Priya', personality: 'concise' },
|
|
113
|
+
actorName: 'Priya',
|
|
114
114
|
agentName: 'todoBot',
|
|
115
115
|
task: 'Get a todo created',
|
|
116
116
|
evaluate: 'A todo now exists',
|
|
@@ -168,7 +168,13 @@ describe('runConversation', () => {
|
|
|
168
168
|
const { target, approveCalls } = alwaysSuspendingTarget()
|
|
169
169
|
|
|
170
170
|
await assert.rejects(
|
|
171
|
-
runConversation({
|
|
171
|
+
runConversation({
|
|
172
|
+
...base,
|
|
173
|
+
approvals: 'always',
|
|
174
|
+
maxApprovalRounds: 3,
|
|
175
|
+
llm,
|
|
176
|
+
target,
|
|
177
|
+
}),
|
|
172
178
|
/approval rounds/
|
|
173
179
|
)
|
|
174
180
|
assert.equal(approveCalls(), 3)
|
|
@@ -11,8 +11,8 @@ import type {
|
|
|
11
11
|
AIAgentStepResult,
|
|
12
12
|
} from '../../services/ai-agent-runner-service.js'
|
|
13
13
|
|
|
14
|
-
/** One turn the
|
|
15
|
-
const
|
|
14
|
+
/** One turn the actor takes: the message to send and whether it's finished. */
|
|
15
|
+
const ACTOR_TURN_SCHEMA = {
|
|
16
16
|
type: 'object',
|
|
17
17
|
properties: {
|
|
18
18
|
message: { type: 'string' },
|
|
@@ -21,7 +21,7 @@ const PERSONA_TURN_SCHEMA = {
|
|
|
21
21
|
required: ['message', 'done'],
|
|
22
22
|
} as const
|
|
23
23
|
|
|
24
|
-
/** The
|
|
24
|
+
/** The actor's approve/deny decision for each pending tool request. */
|
|
25
25
|
const APPROVAL_DECISION_SCHEMA = {
|
|
26
26
|
type: 'object',
|
|
27
27
|
properties: {
|
|
@@ -41,7 +41,7 @@ const APPROVAL_DECISION_SCHEMA = {
|
|
|
41
41
|
required: ['decisions'],
|
|
42
42
|
} as const
|
|
43
43
|
|
|
44
|
-
/** The
|
|
44
|
+
/** The actor's final verdict on whether the task was accomplished. */
|
|
45
45
|
const EVALUATION_SCHEMA = {
|
|
46
46
|
type: 'object',
|
|
47
47
|
properties: {
|
|
@@ -55,23 +55,23 @@ const DEFAULT_MAX_TURNS = 12
|
|
|
55
55
|
|
|
56
56
|
const DEFAULT_MAX_APPROVAL_ROUNDS = 16
|
|
57
57
|
|
|
58
|
-
/** The LLM call the
|
|
59
|
-
export type
|
|
58
|
+
/** The LLM call the actor uses for its own turns/decisions/evaluation. */
|
|
59
|
+
export type ActorLLM = (
|
|
60
60
|
params: AIAgentRunnerParams
|
|
61
61
|
) => Promise<AIAgentStepResult>
|
|
62
62
|
|
|
63
63
|
export interface RunConversationParams {
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
/** Stable
|
|
67
|
-
|
|
64
|
+
/** The actor's own config (personality/jobTitle/name), which shapes how it talks. */
|
|
65
|
+
actor: ScenarioActorConfig
|
|
66
|
+
/** Stable actor name (for transcript labelling). */
|
|
67
|
+
actorName: string
|
|
68
68
|
/** What the actor is trying to get the target agent to accomplish. */
|
|
69
69
|
task: string
|
|
70
70
|
/** Natural-language success criterion the actor evaluates at the end. */
|
|
71
71
|
evaluate: string
|
|
72
72
|
/** How the actor answers the target agent's tool-approval requests. */
|
|
73
73
|
approvals?: ActorFlowApprovalPolicy
|
|
74
|
-
/** Model the
|
|
74
|
+
/** Model the actor uses for its own turns/decisions. */
|
|
75
75
|
model: string
|
|
76
76
|
/** Hard cap on conversation turns. Default 12. */
|
|
77
77
|
maxTurns?: number
|
|
@@ -79,8 +79,8 @@ export interface RunConversationParams {
|
|
|
79
79
|
maxApprovalRounds?: number
|
|
80
80
|
/** Transport that drives the target agent (HTTP in production). */
|
|
81
81
|
target: TargetAgentDriver
|
|
82
|
-
/** The
|
|
83
|
-
llm:
|
|
82
|
+
/** The actor's own LLM. */
|
|
83
|
+
llm: ActorLLM
|
|
84
84
|
/** Display name of the target agent (transcript labelling). */
|
|
85
85
|
agentName: string
|
|
86
86
|
}
|
|
@@ -109,16 +109,13 @@ function readObject<T>(result: { object?: unknown; text?: string }): T | null {
|
|
|
109
109
|
return null
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function
|
|
113
|
-
persona: ScenarioActorConfig,
|
|
114
|
-
task: string
|
|
115
|
-
): string {
|
|
112
|
+
function actorInstructions(actor: ScenarioActorConfig, task: string): string {
|
|
116
113
|
return [
|
|
117
114
|
`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.`,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
? `Your personality and communication style: ${
|
|
115
|
+
actor.name ? `Your name is ${actor.name}.` : '',
|
|
116
|
+
actor.jobTitle ? `Your role: ${actor.jobTitle}.` : '',
|
|
117
|
+
actor.personality
|
|
118
|
+
? `Your personality and communication style: ${actor.personality}. Match this tone, vocabulary, and level of detail exactly.`
|
|
122
119
|
: '',
|
|
123
120
|
`Your goal in this conversation: ${task}.`,
|
|
124
121
|
`Send one message at a time. Set "done" to true only once your goal is clearly accomplished, or clearly impossible.`,
|
|
@@ -127,7 +124,7 @@ function personaInstructions(
|
|
|
127
124
|
.join('\n')
|
|
128
125
|
}
|
|
129
126
|
|
|
130
|
-
/** Route the target agent's pending tool approvals through the
|
|
127
|
+
/** Route the target agent's pending tool approvals through the actor. */
|
|
131
128
|
async function decideApprovals(
|
|
132
129
|
params: RunConversationParams,
|
|
133
130
|
instructions: string,
|
|
@@ -150,7 +147,7 @@ async function decideApprovals(
|
|
|
150
147
|
|
|
151
148
|
const result = await params.llm({
|
|
152
149
|
model: params.model,
|
|
153
|
-
instructions: `${instructions}\nThe assistant is asking permission to run tools on your behalf. Decide whether YOU, as this
|
|
150
|
+
instructions: `${instructions}\nThe assistant is asking permission to run tools on your behalf. Decide whether YOU, as this actor, would allow each one.`,
|
|
154
151
|
messages: [
|
|
155
152
|
msg(
|
|
156
153
|
'user',
|
|
@@ -178,7 +175,7 @@ async function decideApprovals(
|
|
|
178
175
|
})
|
|
179
176
|
}
|
|
180
177
|
|
|
181
|
-
/** Drive the target to a non-suspended reply, routing approvals to the
|
|
178
|
+
/** Drive the target to a non-suspended reply, routing approvals to the actor. */
|
|
182
179
|
async function converseWithTarget(
|
|
183
180
|
params: RunConversationParams,
|
|
184
181
|
instructions: string,
|
|
@@ -209,7 +206,7 @@ async function converseWithTarget(
|
|
|
209
206
|
}
|
|
210
207
|
|
|
211
208
|
/**
|
|
212
|
-
* Run a conversation: an LLM-driven
|
|
209
|
+
* Run a conversation: an LLM-driven actor holds a real multi-turn exchange
|
|
213
210
|
* with a target agent (driven via the injected transport), answers the target's
|
|
214
211
|
* tool-approval requests in-persona, then evaluates whether the task was met.
|
|
215
212
|
* Deterministic checks are the caller's responsibility.
|
|
@@ -218,11 +215,11 @@ export async function runConversation(
|
|
|
218
215
|
params: RunConversationParams
|
|
219
216
|
): Promise<ActorFlowVerdict> {
|
|
220
217
|
const maxTurns = params.maxTurns ?? DEFAULT_MAX_TURNS
|
|
221
|
-
const instructions =
|
|
222
|
-
// Seed a kickoff so the very first
|
|
223
|
-
// (providers reject an empty prompt). It's an instruction TO the
|
|
218
|
+
const instructions = actorInstructions(params.actor, params.task)
|
|
219
|
+
// Seed a kickoff so the very first actor turn has a non-empty message list
|
|
220
|
+
// (providers reject an empty prompt). It's an instruction TO the actor, so
|
|
224
221
|
// it never appears in the transcript.
|
|
225
|
-
const
|
|
222
|
+
const actorMessages: AIMessage[] = [
|
|
226
223
|
msg(
|
|
227
224
|
'user',
|
|
228
225
|
'Begin the conversation now — send your first message to the assistant to work towards your goal.'
|
|
@@ -231,29 +228,27 @@ export async function runConversation(
|
|
|
231
228
|
const transcript: string[] = []
|
|
232
229
|
|
|
233
230
|
for (let turn = 0; turn < maxTurns; turn++) {
|
|
234
|
-
const
|
|
231
|
+
const actorResult = await params.llm({
|
|
235
232
|
model: params.model,
|
|
236
233
|
instructions,
|
|
237
|
-
messages:
|
|
234
|
+
messages: actorMessages,
|
|
238
235
|
tools: [],
|
|
239
236
|
maxSteps: 1,
|
|
240
237
|
toolChoice: 'none',
|
|
241
|
-
outputSchema:
|
|
238
|
+
outputSchema: ACTOR_TURN_SCHEMA as unknown as Record<string, unknown>,
|
|
242
239
|
})
|
|
243
240
|
|
|
244
|
-
const turnData = readObject<{ message: string; done: boolean }>(
|
|
245
|
-
|
|
246
|
-
)
|
|
247
|
-
const personaMessage = turnData?.message?.trim()
|
|
248
|
-
if (!personaMessage) {
|
|
241
|
+
const turnData = readObject<{ message: string; done: boolean }>(actorResult)
|
|
242
|
+
const actorMessage = turnData?.message?.trim()
|
|
243
|
+
if (!actorMessage) {
|
|
249
244
|
break
|
|
250
245
|
}
|
|
251
246
|
|
|
252
|
-
|
|
253
|
-
transcript.push(`${params.
|
|
247
|
+
actorMessages.push(msg('assistant', actorMessage))
|
|
248
|
+
transcript.push(`${params.actorName}: ${actorMessage}`)
|
|
254
249
|
|
|
255
|
-
const reply = await converseWithTarget(params, instructions,
|
|
256
|
-
|
|
250
|
+
const reply = await converseWithTarget(params, instructions, actorMessage)
|
|
251
|
+
actorMessages.push(msg('user', reply.text ?? ''))
|
|
257
252
|
transcript.push(`${params.agentName}: ${reply.text ?? ''}`)
|
|
258
253
|
|
|
259
254
|
if (turnData?.done) {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
assertResourceOwner,
|
|
8
8
|
canAccessThread,
|
|
9
9
|
buildInstructions,
|
|
10
|
+
buildSubAgentRunInput,
|
|
10
11
|
buildToolDefs,
|
|
11
12
|
createScopedChannel,
|
|
12
13
|
getAddonCredentialRequirements,
|
|
@@ -775,3 +776,31 @@ describe('C2 sessionScope + resume ownership', () => {
|
|
|
775
776
|
)
|
|
776
777
|
})
|
|
777
778
|
})
|
|
779
|
+
|
|
780
|
+
describe('buildSubAgentRunInput (parent context forwarding)', () => {
|
|
781
|
+
// A delegated sub-agent's tool-call schema only carries { message, session }.
|
|
782
|
+
// If its run input does not inherit the parent's `context` (the identifier
|
|
783
|
+
// block with organizationId / project ids), the sub-agent never sees the
|
|
784
|
+
// authoritative ids and depends on the model re-typing them into `message` —
|
|
785
|
+
// which weak models botch, causing schema/permission rejections and retry
|
|
786
|
+
// loops. These pin that the parent context is always forwarded.
|
|
787
|
+
test('forwards the parent context onto the sub-agent run input', () => {
|
|
788
|
+
const input = buildSubAgentRunInput(
|
|
789
|
+
'find failing functions',
|
|
790
|
+
'thread-1',
|
|
791
|
+
'org-uuid',
|
|
792
|
+
'organizationId: 11111111-1111-1111-1111-111111111111'
|
|
793
|
+
)
|
|
794
|
+
assert.deepEqual(input, {
|
|
795
|
+
message: 'find failing functions',
|
|
796
|
+
threadId: 'thread-1',
|
|
797
|
+
resourceId: 'org-uuid',
|
|
798
|
+
context: 'organizationId: 11111111-1111-1111-1111-111111111111',
|
|
799
|
+
})
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
test('context is undefined when the parent run had none (root agent)', () => {
|
|
803
|
+
const input = buildSubAgentRunInput('hi', 'thread-1', 'res-1')
|
|
804
|
+
assert.equal(input.context, undefined)
|
|
805
|
+
})
|
|
806
|
+
})
|
|
@@ -476,6 +476,26 @@ export function createScopedChannel(
|
|
|
476
476
|
}
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Build the run input for a delegated sub-agent.
|
|
481
|
+
*
|
|
482
|
+
* `context` is the PARENT run's identifier block (the "Current context" text
|
|
483
|
+
* with organizationId, project/stage ids). A sub-agent's tool-call schema only
|
|
484
|
+
* carries { message, session }, so unless the sub-agent inherits the parent's
|
|
485
|
+
* context it never sees the authoritative ids — it depends on the model
|
|
486
|
+
* re-typing them into `message`, which weak models botch, causing
|
|
487
|
+
* schema/permission rejections and retry loops. Forwarding it here is the
|
|
488
|
+
* regression this seam guards.
|
|
489
|
+
*/
|
|
490
|
+
export function buildSubAgentRunInput(
|
|
491
|
+
message: string,
|
|
492
|
+
threadId: string,
|
|
493
|
+
resourceId: string,
|
|
494
|
+
parentContext?: string
|
|
495
|
+
): { message: string; threadId: string; resourceId: string; context?: string } {
|
|
496
|
+
return { message, threadId, resourceId, context: parentContext }
|
|
497
|
+
}
|
|
498
|
+
|
|
479
499
|
export async function buildToolDefs(
|
|
480
500
|
params: RunAIAgentParams,
|
|
481
501
|
agentSessionMap: Map<string, string>,
|
|
@@ -484,7 +504,15 @@ export async function buildToolDefs(
|
|
|
484
504
|
packageName: string | null,
|
|
485
505
|
streamContext?: StreamContext,
|
|
486
506
|
aiMiddlewares?: PikkuAIMiddlewareHooks[],
|
|
487
|
-
agentMode?: 'delegate' | 'supervise'
|
|
507
|
+
agentMode?: 'delegate' | 'supervise',
|
|
508
|
+
// The parent run's `context` (the "Current context" identifier block). A
|
|
509
|
+
// delegated sub-agent's tool-call input schema only carries { message,
|
|
510
|
+
// session }, so without inheriting this the sub-agent never sees the
|
|
511
|
+
// authoritative ids (organizationId, project/stage ids) — it depends on the
|
|
512
|
+
// model re-typing them into `message`, which weak models botch, causing
|
|
513
|
+
// schema/permission rejections and retry loops. Forward it so the sub-agent
|
|
514
|
+
// gets the same context block in its instructions.
|
|
515
|
+
parentContext?: string
|
|
488
516
|
): Promise<{ tools: AIAgentToolDef[]; missingRpcs: string[] }> {
|
|
489
517
|
const singletonServices = getSingletonServices()
|
|
490
518
|
const tools: AIAgentToolDef[] = []
|
|
@@ -716,7 +744,12 @@ export async function buildToolDefs(
|
|
|
716
744
|
}
|
|
717
745
|
const resultText = await streamAIAgent(
|
|
718
746
|
subAgentName,
|
|
719
|
-
|
|
747
|
+
buildSubAgentRunInput(
|
|
748
|
+
message,
|
|
749
|
+
threadId,
|
|
750
|
+
resourceId,
|
|
751
|
+
parentContext
|
|
752
|
+
),
|
|
720
753
|
effectiveChannel,
|
|
721
754
|
params,
|
|
722
755
|
agentSessionMap,
|
|
@@ -744,7 +777,7 @@ export async function buildToolDefs(
|
|
|
744
777
|
// No stream context: sub-agent runs non-streaming
|
|
745
778
|
const result = await runAIAgent(
|
|
746
779
|
subAgentName,
|
|
747
|
-
|
|
780
|
+
buildSubAgentRunInput(message, threadId, resourceId, parentContext),
|
|
748
781
|
params,
|
|
749
782
|
agentSessionMap
|
|
750
783
|
)
|
|
@@ -1017,7 +1050,8 @@ export async function prepareAgentRun(
|
|
|
1017
1050
|
packageName,
|
|
1018
1051
|
streamContext,
|
|
1019
1052
|
aiMiddlewares,
|
|
1020
|
-
agent.agentMode
|
|
1053
|
+
agent.agentMode,
|
|
1054
|
+
input.context
|
|
1021
1055
|
)
|
|
1022
1056
|
|
|
1023
1057
|
let instructions = await buildInstructions(resolvedName, packageName)
|