@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +397 -0
  2. package/README.md +34 -2
  3. package/dist/function/functions.types.d.ts +27 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/internal.d.ts +1 -1
  6. package/dist/internal.js +1 -1
  7. package/dist/pikku-state.js +1 -0
  8. package/dist/services/http-scenario-actors.d.ts +12 -4
  9. package/dist/services/http-scenario-actors.js +47 -45
  10. package/dist/services/in-memory-workflow-service.d.ts +2 -2
  11. package/dist/services/in-memory-workflow-service.js +2 -2
  12. package/dist/services/index.d.ts +2 -1
  13. package/dist/services/index.js +1 -0
  14. package/dist/services/meta-service.d.ts +5 -1
  15. package/dist/services/meta-service.js +44 -18
  16. package/dist/services/scenario-actors-service.d.ts +108 -2
  17. package/dist/services/scenario-actors-service.js +40 -1
  18. package/dist/types/core.types.d.ts +21 -3
  19. package/dist/types/state.types.d.ts +3 -1
  20. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  21. package/dist/wirings/actor-flow/index.d.ts +1 -1
  22. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  23. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  24. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +18 -1
  25. package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
  26. package/dist/wirings/cli/command-parser.js +11 -1
  27. package/dist/wirings/queue/index.d.ts +1 -1
  28. package/dist/wirings/queue/queue.types.d.ts +30 -0
  29. package/dist/wirings/rpc/rpc-runner.js +1 -1
  30. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  31. package/dist/wirings/workflow/feature.d.ts +28 -0
  32. package/dist/wirings/workflow/feature.js +57 -0
  33. package/dist/wirings/workflow/index.d.ts +13 -2
  34. package/dist/wirings/workflow/index.js +15 -0
  35. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  36. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  37. package/dist/wirings/workflow/pikku-workflow-service.d.ts +118 -12
  38. package/dist/wirings/workflow/pikku-workflow-service.js +166 -153
  39. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  40. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  41. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  42. package/dist/wirings/workflow/scenario-poll.js +25 -0
  43. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  44. package/dist/wirings/workflow/scenario-prose.js +45 -0
  45. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  46. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  47. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  48. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  49. package/dist/wirings/workflow/workflow.types.d.ts +119 -2
  50. package/package.json +3 -1
  51. package/src/function/functions.types.ts +32 -0
  52. package/src/index.ts +1 -0
  53. package/src/internal.ts +5 -1
  54. package/src/pikku-state.ts +1 -0
  55. package/src/services/http-scenario-actors.test.ts +85 -1
  56. package/src/services/http-scenario-actors.ts +65 -51
  57. package/src/services/in-memory-workflow-service.test.ts +50 -1
  58. package/src/services/in-memory-workflow-service.ts +3 -2
  59. package/src/services/index.ts +5 -0
  60. package/src/services/meta-service.test.ts +79 -0
  61. package/src/services/meta-service.ts +61 -26
  62. package/src/services/scenario-actors-service.ts +157 -2
  63. package/src/types/core.types.ts +27 -2
  64. package/src/types/state.types.ts +3 -0
  65. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  66. package/src/wirings/actor-flow/index.ts +1 -1
  67. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  68. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  69. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +29 -0
  70. package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
  71. package/src/wirings/cli/command-parser.test.ts +60 -0
  72. package/src/wirings/cli/command-parser.ts +12 -1
  73. package/src/wirings/queue/index.ts +2 -0
  74. package/src/wirings/queue/queue.types.ts +32 -0
  75. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  76. package/src/wirings/rpc/rpc-runner.ts +1 -1
  77. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  78. package/src/wirings/workflow/feature.test.ts +131 -0
  79. package/src/wirings/workflow/feature.ts +78 -0
  80. package/src/wirings/workflow/index.ts +74 -0
  81. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  82. package/src/wirings/workflow/pikku-workflow-service.test.ts +126 -0
  83. package/src/wirings/workflow/pikku-workflow-service.ts +306 -228
  84. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  85. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  86. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  87. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  88. package/src/wirings/workflow/scenario-poll.ts +36 -0
  89. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  90. package/src/wirings/workflow/scenario-prose.ts +79 -0
  91. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  92. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  93. package/src/wirings/workflow/scenario-step.test.ts +441 -8
  94. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  95. package/src/wirings/workflow/workflow.types.ts +137 -1
  96. 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<TAgentName extends string = string> {
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(rpcName: string, data: unknown): Promise<unknown>
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. */
@@ -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 { ScenarioActors } from '../services/scenario-actors-service.js'
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: ScenarioActors
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>
@@ -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 persona uses for its own turns/decisions. Falls back to the actor service default. */
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
@@ -18,5 +18,5 @@ export type {
18
18
  export {
19
19
  runConversation,
20
20
  type RunConversationParams,
21
- type PersonaLLM,
21
+ type ActorLLM,
22
22
  } from './run-conversation.js'
@@ -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 PersonaLLM } from './run-conversation.js'
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: PersonaLLM; calls: string[] } => {
26
+ }): { llm: ActorLLM; calls: string[] } => {
27
27
  let turn = 0
28
28
  const calls: string[] = []
29
- const llm: PersonaLLM = async (params) => {
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
- persona: { email: 'pm@example.com', name: 'Priya', personality: 'concise' },
113
- personaName: 'Priya',
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({ ...base, approvals: 'always', maxApprovalRounds: 3, llm, target }),
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 persona takes: the message to send and whether it's finished. */
15
- const PERSONA_TURN_SCHEMA = {
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 persona's approve/deny decision for each pending tool request. */
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 persona's final verdict on whether the task was accomplished. */
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 persona uses for its own turns/decisions/evaluation. */
59
- export type PersonaLLM = (
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
- /** Persona config (personality/jobTitle/name) that shapes how the actor talks. */
65
- persona: ScenarioActorConfig
66
- /** Stable persona name (for transcript labelling). */
67
- personaName: string
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 persona uses for its own turns/decisions. */
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 persona's own LLM. */
83
- llm: PersonaLLM
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 personaInstructions(
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
- persona.name ? `Your name is ${persona.name}.` : '',
119
- persona.jobTitle ? `Your role: ${persona.jobTitle}.` : '',
120
- persona.personality
121
- ? `Your personality and communication style: ${persona.personality}. Match this tone, vocabulary, and level of detail exactly.`
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 persona. */
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 persona, would allow each one.`,
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 persona. */
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 persona holds a real multi-turn exchange
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 = personaInstructions(params.persona, params.task)
222
- // Seed a kickoff so the very first persona turn has a non-empty message list
223
- // (providers reject an empty prompt). It's an instruction TO the persona, so
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 personaMessages: AIMessage[] = [
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 personaResult = await params.llm({
231
+ const actorResult = await params.llm({
235
232
  model: params.model,
236
233
  instructions,
237
- messages: personaMessages,
234
+ messages: actorMessages,
238
235
  tools: [],
239
236
  maxSteps: 1,
240
237
  toolChoice: 'none',
241
- outputSchema: PERSONA_TURN_SCHEMA as unknown as Record<string, unknown>,
238
+ outputSchema: ACTOR_TURN_SCHEMA as unknown as Record<string, unknown>,
242
239
  })
243
240
 
244
- const turnData = readObject<{ message: string; done: boolean }>(
245
- personaResult
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
- personaMessages.push(msg('assistant', personaMessage))
253
- transcript.push(`${params.personaName}: ${personaMessage}`)
247
+ actorMessages.push(msg('assistant', actorMessage))
248
+ transcript.push(`${params.actorName}: ${actorMessage}`)
254
249
 
255
- const reply = await converseWithTarget(params, instructions, personaMessage)
256
- personaMessages.push(msg('user', reply.text ?? ''))
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
- { message, threadId, resourceId },
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
- { message, threadId, resourceId },
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)