c8ctl-plugin-nano 1.55.0 → 1.56.0

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.
@@ -0,0 +1,451 @@
1
+ // Engine-native AgentInstance / AgentHistory producer (issue #194).
2
+ //
3
+ // When the harness activates a job whose element carries
4
+ // `zeebe:agentDefinition agentType="external"`, this module mints an engine-native
5
+ // `AgentInstance` (Camunda 8.10 / nanobpmn) and streams the agent's ACP turns into
6
+ // the append-only `AgentHistory` via the host-provided `@camunda8/orchestration-cluster-api`
7
+ // SDK client (`createAgentInstance` / `updateAgentInstance`). This is the DURABLE
8
+ // historical/metrics producer that replaces the fragile app-side transcript relay
9
+ // side-channel — the relay stays as a live overlay; nothing here removes it.
10
+ //
11
+ // The ACP → AgentHistory translation is a port of the canonical
12
+ // `@nanobpm/agentic/session/acp` `classifyUpdate` bridge (the same classifier the
13
+ // transcript-chunk producer uses), so a producer and the engine's read model can
14
+ // never diverge on the semantic shape of a turn.
15
+ //
16
+ // Everything at the process edge is injected (the SDK client, the classifier, the
17
+ // clock), so the producer is driven deterministically under `node --test` with an
18
+ // in-memory fake client. The producer is ENTIRELY best-effort: a failure to mint or
19
+ // append an AgentInstance must NEVER crash the harness or change `job.complete`
20
+ // behaviour — the AgentInstance lifecycle is orthogonal to job completion.
21
+
22
+ import { sessionAcp as defaultSessionAcp } from './agentic.mjs';
23
+
24
+ // The two AgentInstance surfaces we call on the host SDK client. A client missing
25
+ // either method (an older SDK) disables the producer rather than throwing.
26
+ const SDK_CREATE = 'createAgentInstance';
27
+ const SDK_UPDATE = 'updateAgentInstance';
28
+
29
+ const isNonBlank = (v) => v != null && String(v).trim() !== '';
30
+ const isPlainObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
31
+
32
+ /**
33
+ * Is this activated job an `external` (job-backed) agent job — i.e. one whose
34
+ * element carries `zeebe:agentDefinition agentType="external"`?
35
+ *
36
+ * The engine surfaces exactly this eligibility as the pair of fields it stamps on
37
+ * an external agent job's activation: an opaque per-activation `jobLease` token
38
+ * (distinct from the job's `deadline`; nanobpmn #1106) plus the `elementInstanceKey`
39
+ * the AgentInstance correlates on. The engine-native `aiAgentTask`/`aiAgentSubProcess`
40
+ * variants auto-mint their AgentInstance and never create an activatable job, so any
41
+ * job a worker actually activates that carries a lease token IS an external agent
42
+ * job. Absence of either field means "not an external agent job" → the producer
43
+ * stays fully inert (no behaviour change for ordinary service jobs).
44
+ */
45
+ export function isExternalAgentJob(job) {
46
+ if (!isPlainObject(job)) return false;
47
+ return isNonBlank(job.jobLease) && isNonBlank(job.elementInstanceKey);
48
+ }
49
+
50
+ /** Best-effort provider inference from a model identifier (openai/anthropic/…). */
51
+ export function inferProvider(model) {
52
+ const m = String(model || '').toLowerCase();
53
+ if (!m) return 'unknown';
54
+ if (/(gpt|o\d|davinci|openai)/.test(m)) return 'openai';
55
+ if (/(claude|opus|sonnet|haiku|anthropic)/.test(m)) return 'anthropic';
56
+ if (/(gemini|palm|bison|google)/.test(m)) return 'google';
57
+ if (/(llama|meta)/.test(m)) return 'meta';
58
+ if (/(mistral|mixtral)/.test(m)) return 'mistral';
59
+ if (/(deepseek)/.test(m)) return 'deepseek';
60
+ if (/(qwen)/.test(m)) return 'qwen';
61
+ if (/(kimi|moonshot)/.test(m)) return 'moonshot';
62
+ if (/(grok|xai)/.test(m)) return 'xai';
63
+ return 'unknown';
64
+ }
65
+
66
+ /**
67
+ * Seed the concrete agent `definition` (model, provider, systemPrompt) from the
68
+ * actual worker/model that claimed the job. The static `agentDefinition` marker is
69
+ * only the eligibility flag; the concrete definition is runtime — the model comes
70
+ * from the worker profile, the systemPrompt from the resolved base prompt.
71
+ */
72
+ export function deriveAgentDefinition({ profile, envelope } = {}) {
73
+ const model = isNonBlank(profile?.model) ? String(profile.model) : 'unknown';
74
+ const provider = isNonBlank(profile?.provider)
75
+ ? String(profile.provider)
76
+ : inferProvider(model);
77
+ const systemPrompt = isNonBlank(envelope?.task?.prompt) ? String(envelope.task.prompt) : '';
78
+ return { model, provider, systemPrompt };
79
+ }
80
+
81
+ // Map the ACP classifier's message role to the AgentHistory role enum. ACP has no
82
+ // distinct REASONING role, so a `reasoning` chunk folds into ASSISTANT.
83
+ function historyRole(acpRole) {
84
+ return acpRole === 'user' ? 'USER' : 'ASSISTANT';
85
+ }
86
+
87
+ // A short, stable hash for deriving a content-addressed historyItemId when the ACP
88
+ // agent omits a messageId (so identical retried content dedups; new content appends).
89
+ function shortHash(text) {
90
+ let h = 5381;
91
+ const s = String(text);
92
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
93
+ return (h >>> 0).toString(36);
94
+ }
95
+
96
+ // Extract per-call metrics from an ACP update when the agent carries them (many
97
+ // ACP agents do not — a documented ACP fidelity gap — so this is usually absent).
98
+ // Reads the common usage locations and maps to the AgentHistory item metric field
99
+ // names. Returns undefined when nothing usable is present.
100
+ function extractMetrics(update) {
101
+ const src =
102
+ (isPlainObject(update?.usage) && update.usage) ||
103
+ (isPlainObject(update?.tokenUsage) && update.tokenUsage) ||
104
+ (isPlainObject(update?._meta?.usage) && update._meta.usage) ||
105
+ (isPlainObject(update?.metrics) && update.metrics) ||
106
+ null;
107
+ if (!src) return undefined;
108
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined);
109
+ const out = {};
110
+ const inputTokens = num(src.inputTokens ?? src.input_tokens ?? src.promptTokens ?? src.prompt_tokens);
111
+ const outputTokens = num(src.outputTokens ?? src.output_tokens ?? src.completionTokens ?? src.completion_tokens);
112
+ const reasoningTokenCount = num(src.reasoningTokenCount ?? src.reasoning_tokens ?? src.reasoningTokens);
113
+ const cacheCreationTokenCount = num(src.cacheCreationTokenCount ?? src.cache_creation_input_tokens ?? src.cacheCreationInputTokens);
114
+ const cacheReadTokenCount = num(src.cacheReadTokenCount ?? src.cache_read_input_tokens ?? src.cacheReadInputTokens);
115
+ const durationMs = num(src.durationMs ?? src.duration_ms ?? src.latencyMs ?? src.latency_ms);
116
+ if (inputTokens !== undefined) out.inputTokens = inputTokens;
117
+ if (outputTokens !== undefined) out.outputTokens = outputTokens;
118
+ if (reasoningTokenCount !== undefined) out.reasoningTokenCount = reasoningTokenCount;
119
+ if (cacheCreationTokenCount !== undefined) out.cacheCreationTokenCount = cacheCreationTokenCount;
120
+ if (cacheReadTokenCount !== undefined) out.cacheReadTokenCount = cacheReadTokenCount;
121
+ if (durationMs !== undefined) out.durationMs = durationMs;
122
+ return Object.keys(out).length ? out : undefined;
123
+ }
124
+
125
+ // A tool-result's `result` becomes an OBJECT content block when it is structured
126
+ // JSON, or a TEXT block when it is a plain string. Null/undefined → empty content.
127
+ function contentForResult(result) {
128
+ if (result == null) return [];
129
+ if (typeof result === 'string') return [{ contentType: 'TEXT', text: result }];
130
+ return [{ contentType: 'OBJECT', object: result }];
131
+ }
132
+
133
+ /**
134
+ * Create an AgentInstance producer bound to one activated external agent job.
135
+ *
136
+ * Returns a small facade the harness drives:
137
+ * - `activate()` — mint the AgentInstance (lease-gated on the job) with the
138
+ * opening CONFIGURATION turn. Idempotent: correlates on the elementInstanceKey,
139
+ * so a reactivation (ask-a-question loop) folds into the SAME instance and never
140
+ * creates a second one.
141
+ * - `ingest(rawUpdate)` — feed one raw ACP `session/update` (non-blocking; the SDK
142
+ * append is enqueued so the ACP hot path never blocks).
143
+ * - `complete(ok)` — drain pending appends, then (on a successful job end) update
144
+ * the instance status to COMPLETED. `job.complete` is unchanged and orthogonal.
145
+ *
146
+ * @param {object} opts
147
+ * @param {object} opts.camunda Host SDK client (createAgentInstance/updateAgentInstance).
148
+ * @param {object} opts.job The activated job (jobKey/jobLease/elementInstanceKey/elementId).
149
+ * @param {object} [opts.profile] The worker profile (model/provider seed the definition).
150
+ * @param {object} [opts.envelope] The normalized task envelope (task.prompt → systemPrompt).
151
+ * @param {object} [opts.logger] Output-mode-aware logger (warn/info/debug).
152
+ * @param {() => number} [opts.now] Injected clock (ms epoch); defaults to Date.now.
153
+ * @param {object} [opts.sessionAcp] The ACP classifier surface (defaults to the package bridge).
154
+ */
155
+ export function createAgentInstanceProducer(opts = {}) {
156
+ const {
157
+ camunda,
158
+ job,
159
+ profile = {},
160
+ envelope = {},
161
+ logger = console,
162
+ now = () => Date.now(),
163
+ sessionAcp = defaultSessionAcp,
164
+ } = opts;
165
+
166
+ const classify = typeof sessionAcp?.classifyUpdate === 'function' ? sessionAcp.classifyUpdate : null;
167
+
168
+ const jobKey = job?.jobKey != null ? String(job.jobKey) : '';
169
+ const jobLease = job?.jobLease != null ? String(job.jobLease) : '';
170
+ const elementInstanceKey = job?.elementInstanceKey != null ? String(job.elementInstanceKey) : '';
171
+ const elementId = job?.elementId != null ? String(job.elementId) : null;
172
+
173
+ // The producer is a no-op unless every precondition holds: a usable SDK client,
174
+ // an external agent job, and the ACP classifier. Any missing piece leaves the
175
+ // harness path byte-for-byte unchanged.
176
+ const usable =
177
+ !!camunda &&
178
+ typeof camunda[SDK_CREATE] === 'function' &&
179
+ typeof camunda[SDK_UPDATE] === 'function' &&
180
+ !!classify &&
181
+ isExternalAgentJob(job);
182
+
183
+ let disabled = !usable;
184
+ let agentInstanceKey = null;
185
+ let activated = false;
186
+ let loopIteration = 0;
187
+ let queue = Promise.resolve();
188
+ // Coalesce streamed message chunks (same messageId + role) into one turn, flushed
189
+ // on a role/message boundary, a tool event, or completion — the engine dedups on
190
+ // historyItemId (it does NOT merge), so a turn must be appended exactly once, whole.
191
+ let pendingMessage = null;
192
+ // callId → toolName, so a TOOL_RESULT turn can reference the originating call name.
193
+ const toolNames = new Map();
194
+
195
+ const iso = () => new Date(now()).toISOString();
196
+
197
+ // Serialize an SDK call onto the queue so appends preserve order and `complete`
198
+ // can drain them. A rejection is swallowed (best-effort) but never breaks the chain.
199
+ const enqueue = (fn) => {
200
+ queue = queue.then(fn).catch((err) => {
201
+ logger?.debug?.(`AgentInstance producer: SDK call failed — ${err?.message || err}`);
202
+ });
203
+ return queue;
204
+ };
205
+
206
+ // Append one AgentHistory turn via updateAgentInstance (one turn per call keeps the
207
+ // dedup boundary crisp). Only ever runs once the instance is minted.
208
+ const appendTurn = (turn, status) => {
209
+ if (disabled || !agentInstanceKey) return;
210
+ enqueue(async () => {
211
+ const req = {
212
+ agentInstanceKey,
213
+ elementInstanceKey,
214
+ jobKey,
215
+ jobLease,
216
+ history: [turn],
217
+ };
218
+ if (status) req.status = status;
219
+ await camunda[SDK_UPDATE](req);
220
+ });
221
+ };
222
+
223
+ const flushMessage = () => {
224
+ if (!pendingMessage) return;
225
+ const text = pendingMessage.texts.join('');
226
+ const hasText = text.trim() !== '';
227
+ const hasMetrics = pendingMessage.metrics !== undefined;
228
+ const msg = pendingMessage;
229
+ pendingMessage = null;
230
+ if (!hasText && !hasMetrics) return;
231
+ const idBasis = isNonBlank(msg.messageId) ? String(msg.messageId) : `h:${shortHash(text)}`;
232
+ const turn = {
233
+ historyItemId: `${msg.role.toLowerCase()}:${idBasis}`,
234
+ loopIteration: msg.loopIteration,
235
+ role: msg.role,
236
+ content: hasText ? [{ contentType: 'TEXT', text }] : [],
237
+ producedAt: msg.producedAt,
238
+ };
239
+ if (msg.role === 'ASSISTANT' && hasMetrics) turn.metrics = msg.metrics;
240
+ appendTurn(turn, msg.role === 'ASSISTANT' ? 'THINKING' : undefined);
241
+ // A completed ASSISTANT response ends one loop iteration.
242
+ if (msg.role === 'ASSISTANT') loopIteration += 1;
243
+ };
244
+
245
+ const onToolCall = (c) => {
246
+ flushMessage();
247
+ if (isNonBlank(c.name)) toolNames.set(String(c.callId), String(c.name));
248
+ const turn = {
249
+ historyItemId: `toolcall:${c.callId}`,
250
+ loopIteration,
251
+ role: 'ASSISTANT',
252
+ content: [],
253
+ toolCalls: [
254
+ {
255
+ toolCallId: String(c.callId),
256
+ toolName: isNonBlank(c.name) ? String(c.name) : '',
257
+ elementId,
258
+ arguments: isPlainObject(c.args) ? c.args : null,
259
+ },
260
+ ],
261
+ producedAt: iso(),
262
+ };
263
+ appendTurn(turn, 'TOOL_CALLING');
264
+ };
265
+
266
+ const onToolResult = (c) => {
267
+ flushMessage();
268
+ const turn = {
269
+ historyItemId: `toolresult:${c.callId}`,
270
+ loopIteration,
271
+ role: 'TOOL_RESULT',
272
+ content: contentForResult(c.result),
273
+ toolCalls: [
274
+ {
275
+ toolCallId: String(c.callId),
276
+ toolName: toolNames.get(String(c.callId)) || '',
277
+ elementId,
278
+ arguments: null,
279
+ },
280
+ ],
281
+ producedAt: iso(),
282
+ };
283
+ appendTurn(turn);
284
+ };
285
+
286
+ return {
287
+ /** True once the AgentInstance has been minted (or is being minted). */
288
+ get active() {
289
+ return activated && !disabled;
290
+ },
291
+ get agentInstanceKey() {
292
+ return agentInstanceKey;
293
+ },
294
+
295
+ /**
296
+ * Mint the AgentInstance (lease-gated) with an opening CONFIGURATION turn that
297
+ * establishes model/provider/systemPrompt/limits. Idempotent per element
298
+ * instance — safe to call once per activation; a reactivation reconciles onto
299
+ * the same instance rather than creating a second one. Best-effort: a rejected
300
+ * create (e.g. a stale lease) disables the producer and returns false.
301
+ */
302
+ async activate() {
303
+ if (disabled || activated) return this.active;
304
+ activated = true;
305
+ const def = deriveAgentDefinition({ profile, envelope });
306
+ const configTurn = {
307
+ historyItemId: `configuration:${elementInstanceKey}`,
308
+ loopIteration: 0,
309
+ role: 'CONFIGURATION',
310
+ content: [],
311
+ producedAt: iso(),
312
+ model: def.model,
313
+ provider: def.provider,
314
+ };
315
+ if (isNonBlank(def.systemPrompt)) {
316
+ configTurn.systemPrompt = [{ contentType: 'TEXT', text: def.systemPrompt }];
317
+ }
318
+ const limits = deriveLimits(envelope);
319
+ if (limits) configTurn.limits = limits;
320
+ try {
321
+ const res = await camunda[SDK_CREATE]({
322
+ elementInstanceKey,
323
+ jobKey,
324
+ jobLease,
325
+ history: [configTurn],
326
+ });
327
+ agentInstanceKey =
328
+ (res && (res.agentInstanceKey ?? res.key)) != null
329
+ ? String(res.agentInstanceKey ?? res.key)
330
+ : null;
331
+ if (!agentInstanceKey) {
332
+ // A reactivation reconciles the auto-minted/existing record; if the create
333
+ // result carries no key, fall back to the elementInstanceKey correlation is
334
+ // not possible for updates (they need the agentInstanceKey), so disable.
335
+ disabled = true;
336
+ logger?.warn?.('AgentInstance producer: create returned no agentInstanceKey; disabling durable transcript for this job.');
337
+ return false;
338
+ }
339
+ loopIteration = 1;
340
+ logger?.info?.(`AgentInstance ${agentInstanceKey} minted for element instance ${elementInstanceKey} (job ${jobKey}).`);
341
+ return true;
342
+ } catch (err) {
343
+ disabled = true;
344
+ logger?.warn?.(`AgentInstance producer: createAgentInstance failed — ${err?.message || err}; continuing without a durable transcript (job completion unaffected).`);
345
+ return false;
346
+ }
347
+ },
348
+
349
+ /**
350
+ * Feed one raw ACP `session/update` (the `params.update`). Non-blocking: the
351
+ * translated turn's SDK append is enqueued. Malformed/ignored updates are
352
+ * dropped. Never throws.
353
+ */
354
+ ingest(rawUpdate) {
355
+ if (disabled || !agentInstanceKey) return;
356
+ let classified;
357
+ try {
358
+ classified = classify(rawUpdate);
359
+ } catch {
360
+ return;
361
+ }
362
+ if (!classified || typeof classified !== 'object') return;
363
+ try {
364
+ switch (classified.kind) {
365
+ case 'message': {
366
+ const role = historyRole(classified.role);
367
+ if (
368
+ pendingMessage &&
369
+ (pendingMessage.messageId !== classified.messageId || pendingMessage.role !== role)
370
+ ) {
371
+ flushMessage();
372
+ }
373
+ if (!pendingMessage) {
374
+ pendingMessage = {
375
+ role,
376
+ messageId: classified.messageId ?? null,
377
+ texts: [],
378
+ metrics: undefined,
379
+ loopIteration,
380
+ producedAt: iso(),
381
+ };
382
+ }
383
+ if (isNonBlank(classified.text)) pendingMessage.texts.push(String(classified.text));
384
+ const m = extractMetrics(rawUpdate);
385
+ if (m) pendingMessage.metrics = { ...(pendingMessage.metrics || {}), ...m };
386
+ break;
387
+ }
388
+ case 'tool-call':
389
+ onToolCall(classified);
390
+ break;
391
+ case 'tool-result':
392
+ onToolResult(classified);
393
+ break;
394
+ default:
395
+ break;
396
+ }
397
+ } catch (err) {
398
+ logger?.debug?.(`AgentInstance producer: ingest failed — ${err?.message || err}`);
399
+ }
400
+ },
401
+
402
+ /** Drain any queued appends without transitioning status. */
403
+ async drain() {
404
+ flushMessage();
405
+ await queue;
406
+ },
407
+
408
+ /**
409
+ * End the AgentInstance lifecycle. Flushes any pending message turn, drains the
410
+ * append queue, then — only on a SUCCESSFUL job end (`ok`) — updates the instance
411
+ * status to COMPLETED (there is no separate completeAgentInstance verb). On a
412
+ * failed run the instance is left non-terminal so a retry/reactivation continues
413
+ * the same instance. Best-effort; never throws; `job.complete` is unaffected.
414
+ */
415
+ async complete(ok = true) {
416
+ if (disabled || !agentInstanceKey) {
417
+ // Still drain any queued appends so a caller awaiting completion settles.
418
+ try { await this.drain(); } catch { /* best effort */ }
419
+ return;
420
+ }
421
+ flushMessage();
422
+ if (ok) {
423
+ enqueue(async () => {
424
+ await camunda[SDK_UPDATE]({
425
+ agentInstanceKey,
426
+ elementInstanceKey,
427
+ jobKey,
428
+ jobLease,
429
+ status: 'COMPLETED',
430
+ });
431
+ });
432
+ }
433
+ try { await queue; } catch { /* best effort */ }
434
+ },
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Derive the agent execution `limits` from the task envelope, when present. Maps the
440
+ * envelope's `task.maxIterations` onto `maxModelCalls`; token/tool ceilings are not
441
+ * expressed in the envelope today, so they are left as -1 (no limit). Returns
442
+ * undefined when nothing constrains the run (the create then defaults all to -1).
443
+ */
444
+ export function deriveLimits(envelope) {
445
+ const maxIterations = envelope?.task?.maxIterations;
446
+ const n = Number(maxIterations);
447
+ if (Number.isFinite(n) && n > 0) {
448
+ return { maxModelCalls: Math.trunc(n), maxToolCalls: -1, maxTokens: -1 };
449
+ }
450
+ return undefined;
451
+ }
package/c8ctl-plugin.js CHANGED
@@ -72,6 +72,10 @@ import { createLogRing, resolveLogMaxBytes } from './supervisor-log-ring.mjs';
72
72
  // raw ACP `session/update` to the exact transcript-chunk bytes the cockpit decodes,
73
73
  // replacing the plugin's former hand-rolled `nwfTranscriptEvent` envelope grammar.
74
74
  import { sessionAcp as agenticSessionAcp } from './agentic.mjs';
75
+ // Engine-native AgentInstance / AgentHistory durable-transcript producer (issue
76
+ // #194): mints an AgentInstance for an `external` agent job and appends each ACP
77
+ // turn to the engine's append-only AgentHistory via the host SDK client.
78
+ import { createAgentInstanceProducer, isExternalAgentJob } from './agent-instance.mjs';
75
79
 
76
80
  const requireFromHere = createRequire(import.meta.url);
77
81
  const pluginDir = dirname(fileURLToPath(import.meta.url));
@@ -4879,7 +4883,7 @@ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
4879
4883
  // and every caller work unchanged. Because the raw stream is JSON-RPC (not human
4880
4884
  // output), `stdout` here is the accumulated human-readable transcript text (what
4881
4885
  // we relay), and `stderr` is the child's real stderr (agent diagnostics).
4882
- function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false }) {
4886
+ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false, onAcpUpdate = null }) {
4883
4887
  return new Promise((resolve) => {
4884
4888
  const logger = getLogger();
4885
4889
  const humanChunks = [];
@@ -5222,7 +5226,17 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
5222
5226
  }
5223
5227
  // A request or notification FROM the agent.
5224
5228
  if (typeof msg.method === 'string') {
5225
- if (msg.method === 'session/update') { emitTranscript(msg.params?.update); return; }
5229
+ if (msg.method === 'session/update') {
5230
+ emitTranscript(msg.params?.update);
5231
+ // #194: durable engine-native producer seam. In parallel with the
5232
+ // transcript-chunk relay overlay, hand the SAME raw `session/update` to
5233
+ // the AgentInstance producer so each ACP turn is appended to the engine's
5234
+ // append-only AgentHistory. Best-effort and non-blocking (the producer
5235
+ // enqueues its own SDK append), so a producer failure never disturbs the
5236
+ // ACP loop or the transcript relay. Inert when no producer is wired.
5237
+ if (onAcpUpdate) { try { onAcpUpdate(msg.params?.update); } catch { /* producer best-effort */ } }
5238
+ return;
5239
+ }
5226
5240
  if (msg.method === 'session/request_permission') {
5227
5241
  if (msg.id !== undefined) handlePermission(msg.id, msg.params);
5228
5242
  return;
@@ -5540,7 +5554,7 @@ function baseAgentEnv(profile, job) {
5540
5554
  * Both paths resolve to the same result contract.
5541
5555
  */
5542
5556
  function runAgentJob(profile, job, opts = {}) {
5543
- const { timeoutMs, idleTimeoutMs, recoveryWindowMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory, nudgePayload = null } = opts;
5557
+ const { timeoutMs, idleTimeoutMs, recoveryWindowMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory, nudgePayload = null, onAcpUpdate = null } = opts;
5544
5558
  // #110: `protocol`/`permission` drive the ACP executor branch below. The
5545
5559
  // pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
5546
5560
  // A `nudgePayload` (#678) carries the bespoke "re-emit your result" prompt for a
@@ -5628,6 +5642,7 @@ function runAgentJob(profile, job, opts = {}) {
5628
5642
  onStreamOut,
5629
5643
  onStreamErr,
5630
5644
  permission,
5645
+ onAcpUpdate,
5631
5646
  });
5632
5647
  }
5633
5648
 
@@ -7501,6 +7516,25 @@ async function workAgent(req, flags) {
7501
7516
  const runId = randomUUID();
7502
7517
  if (isContainer) liveRunIds.add(runId);
7503
7518
 
7519
+ // #194: durable engine-native AgentInstance producer. For an `external`
7520
+ // agent job (one carrying the activation's lease token + elementInstanceKey)
7521
+ // mint an AgentInstance now — lease-gated on THIS activation — seeding the
7522
+ // concrete definition (model/provider/systemPrompt) from the worker/model
7523
+ // and task prompt. Correlated on the elementInstanceKey, so a reactivation
7524
+ // (ask-a-question loop) folds into the SAME instance rather than a second.
7525
+ // Entirely best-effort and orthogonal to job completion: a mint failure (or
7526
+ // the kill-switch NANO_AGENT_INSTANCE=off) leaves the harness path unchanged.
7527
+ // Each ACP `session/update` is fed to `producer.ingest` (wired via runOpts'
7528
+ // `onAcpUpdate`), which appends the translated turn to AgentHistory; on job
7529
+ // end the instance is driven to COMPLETED (success only) — `job.complete`
7530
+ // fires exactly as before regardless.
7531
+ let agentInstanceProducer = null;
7532
+ const agentInstanceOff = String(process.env.NANO_AGENT_INSTANCE || '').trim().toLowerCase() === 'off';
7533
+ if (!agentInstanceOff && isExternalAgentJob(job)) {
7534
+ agentInstanceProducer = createAgentInstanceProducer({ camunda, job, profile, envelope, logger });
7535
+ try { await agentInstanceProducer.activate(); } catch { /* best effort */ }
7536
+ }
7537
+
7504
7538
  // Fail-closed on a half-specified repository envelope (issue #129,
7505
7539
  // hardening 2): a `repository` block that declares intent (any field set)
7506
7540
  // but whose `url` is absent or not a usable clone target almost always
@@ -7661,6 +7695,11 @@ async function workAgent(req, flags) {
7661
7695
  // spying never corrupts a structured/JSON output mode.
7662
7696
  onStreamOut: stream ? (line) => logger.info(line) : undefined,
7663
7697
  onStreamErr: stream ? (line) => logger.warn(line) : undefined,
7698
+ // #194: feed each raw ACP `session/update` to the durable AgentInstance
7699
+ // producer so its turn is appended to the engine's AgentHistory. Inert
7700
+ // when no producer was minted (non-external job / off / mint failed) and
7701
+ // for non-ACP harnesses (no session/updates are emitted). Best-effort.
7702
+ onAcpUpdate: agentInstanceProducer ? (u) => agentInstanceProducer.ingest(u) : undefined,
7664
7703
  };
7665
7704
  result = await runAgentJob(profile, job, runOpts);
7666
7705
 
@@ -7698,6 +7737,14 @@ async function workAgent(req, flags) {
7698
7737
  }
7699
7738
  }
7700
7739
 
7740
+ // #194: end the AgentInstance lifecycle. Flush any pending turn and drain
7741
+ // the append queue, then drive the instance to COMPLETED on a successful
7742
+ // job end (a failed run leaves it non-terminal so a retry/reactivation
7743
+ // continues the same instance). Best-effort — never disturbs job settlement.
7744
+ if (agentInstanceProducer) {
7745
+ try { await agentInstanceProducer.complete(result.ok); } catch { /* best effort */ }
7746
+ }
7747
+
7701
7748
  // Finalize git only when the harness succeeded — never push a
7702
7749
  // half-finished workspace.
7703
7750
  if (provisioned && result.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.55.0",
3
+ "version": "1.56.0",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -21,6 +21,7 @@
21
21
  ],
22
22
  "files": [
23
23
  "c8ctl-plugin.js",
24
+ "agent-instance.mjs",
24
25
  "platforms.mjs",
25
26
  "agentic.mjs",
26
27
  "agentic-loader-hook.mjs",
@@ -71,12 +72,12 @@
71
72
  },
72
73
  "optionalDependencies": {
73
74
  "node-pty": "^1.0.0",
74
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.55.0",
75
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.55.0",
76
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.55.0",
77
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.55.0",
78
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.55.0",
79
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.55.0",
80
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.55.0"
75
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.56.0",
76
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.56.0",
77
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.56.0",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.56.0",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.56.0",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.56.0",
81
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.56.0"
81
82
  }
82
83
  }
@@ -140,6 +140,18 @@ function mapJob(raw) {
140
140
  }
141
141
  const pik = raw.processInstanceKey;
142
142
  if (pik !== undefined && pik !== null) job.processInstanceKey = String(pik);
143
+ // Engine-native AgentInstance attribution (issue #194): the element instance the
144
+ // AgentInstance correlates on, the element id, and the opaque per-activation
145
+ // `jobLease` token that lease-gates a `createAgentInstance` for an `external`
146
+ // agent job (nanobpmn #1099/#1106). These ride opaquely to the runner exactly as
147
+ // the SDK activation surfaces them; absent for ordinary (non-agent) jobs, in which
148
+ // case the durable-transcript producer stays inert.
149
+ const eik = raw.elementInstanceKey;
150
+ if (eik !== undefined && eik !== null) job.elementInstanceKey = String(eik);
151
+ const eid = raw.elementId;
152
+ if (eid !== undefined && eid !== null) job.elementId = String(eid);
153
+ const lease = raw.jobLease;
154
+ if (lease !== undefined && lease !== null) job.jobLease = String(lease);
143
155
  return job;
144
156
  }
145
157