@nanobpm/urban 0.92.1 → 0.93.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.
@@ -13,6 +13,11 @@
13
13
 
14
14
  import type {
15
15
  EngineClient,
16
+ AgentHistoryFilter,
17
+ AgentHistoryRecord,
18
+ AgentInstanceFilter,
19
+ AgentInstanceMetrics,
20
+ AgentInstanceSummary,
16
21
  ElementInstanceState,
17
22
  ElementInstanceSummary,
18
23
  ElementInstanceFilter,
@@ -36,6 +41,13 @@ import type {
36
41
  VariableSummary,
37
42
  WorkerSubscription,
38
43
  } from "../core/host.ts";
44
+ import type {
45
+ TranscriptContentBlock,
46
+ TranscriptContentType,
47
+ TranscriptToolCall,
48
+ TranscriptTurnMetrics,
49
+ TranscriptTurnRole,
50
+ } from "@nanobpm/agentic/transcript";
39
51
  import { assertDeployedWaitStateType, isBpmnError } from "../core/host.ts";
40
52
  import {
41
53
  buildFormSchema,
@@ -457,6 +469,202 @@ export function mapJobRow(
457
469
  };
458
470
  }
459
471
 
472
+ /** The agent-instance conversation roles the engine's `AgentInstanceHistoryRoleEnum` reports —
473
+ * a strict subset of the transcript {@link TranscriptTurnRole} union it maps onto, so the two
474
+ * cannot drift. A row whose role is anything else is normalized to `"UNSPECIFIED"`. */
475
+ const AGENT_HISTORY_ROLES: readonly TranscriptTurnRole[] = ["USER", "ASSISTANT", "TOOL_RESULT"];
476
+
477
+ /** Map an engine history-item role onto the transcript {@link TranscriptTurnRole} union, or
478
+ * `"UNSPECIFIED"` when the engine reports an unknown/absent role — a lossy-but-safe fallback so a
479
+ * record is surfaced rather than dropped for a role the engine grows later. */
480
+ function normalizeAgentHistoryRole(value: unknown): TranscriptTurnRole {
481
+ const role = AGENT_HISTORY_ROLES.find((r) => r === value);
482
+ return role ?? "UNSPECIFIED";
483
+ }
484
+
485
+ /** The content-block type discriminators the engine's `AgentInstanceMessageContentTypeEnum`
486
+ * reports, mapped onto the transcript {@link TranscriptContentType} union. */
487
+ const AGENT_CONTENT_TYPES: readonly TranscriptContentType[] = ["TEXT", "DOCUMENT", "OBJECT"];
488
+
489
+ /** Map one engine `AgentInstanceMessageContent` row onto a transcript {@link TranscriptContentBlock},
490
+ * or `undefined` when malformed. TEXT carries `text`, DOCUMENT carries `documentReference`, OBJECT
491
+ * carries `object` — exactly the per-`contentType` payload the transcript shape pins, so the
492
+ * engine-read seam and the transcript store project content identically (No Drift Surfaces). An
493
+ * unknown/absent discriminator maps to an `UNSPECIFIED` block carrying no payload. */
494
+ function mapAgentContentBlock(value: unknown): TranscriptContentBlock | undefined {
495
+ if (!isRecord(value)) return undefined;
496
+ const contentType = AGENT_CONTENT_TYPES.find((t) => t === value.contentType) ?? "UNSPECIFIED";
497
+ if (contentType === "TEXT") {
498
+ return { contentType, text: typeof value.text === "string" ? value.text : "" };
499
+ }
500
+ if (contentType === "DOCUMENT") {
501
+ const documentReference = presentText(value.documentReference);
502
+ return { contentType, documentReference: documentReference ?? "" };
503
+ }
504
+ if (contentType === "OBJECT") {
505
+ return { contentType, object: value.object };
506
+ }
507
+ return { contentType };
508
+ }
509
+
510
+ /** Map one engine `AgentInstanceToolCall` row onto a transcript {@link TranscriptToolCall}, or
511
+ * `undefined` when it carries no `toolCallId`/`toolName` (the identity a caller correlates on). A
512
+ * TOOL_RESULT item reports `arguments: null`; normalize that to an empty object so the
513
+ * transcript shape's non-null `arguments` invariant holds. */
514
+ function mapAgentToolCall(value: unknown): TranscriptToolCall | undefined {
515
+ if (!isRecord(value)) return undefined;
516
+ const toolCallId = presentText(value.toolCallId);
517
+ const toolName = presentText(value.toolName);
518
+ if (toolCallId === undefined || toolName === undefined) return undefined;
519
+ const elementId = presentText(value.elementId);
520
+ const args = isRecord(value.arguments) ? value.arguments : {};
521
+ return {
522
+ toolCallId,
523
+ toolName,
524
+ ...(elementId ? { elementId } : {}),
525
+ arguments: args,
526
+ };
527
+ }
528
+
529
+ /** Map one engine `AgentInstanceHistoryItemMetrics` onto per-turn {@link TranscriptTurnMetrics},
530
+ * or `undefined` when the engine recorded none (a `null` metrics object). The engine's per-item
531
+ * metrics carry only `inputTokens`/`outputTokens`/`durationMs` (each nullable); the transcript
532
+ * shape additionally models reasoning/cache token counts the engine does not report per item, so
533
+ * those default to `0` (the "none reported" count) — a coercion, not a second projection. */
534
+ function mapAgentHistoryMetrics(value: unknown): TranscriptTurnMetrics | undefined {
535
+ if (!isRecord(value)) return undefined;
536
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
537
+ return {
538
+ inputTokens: num(value.inputTokens),
539
+ outputTokens: num(value.outputTokens),
540
+ reasoningTokenCount: 0,
541
+ cacheCreationTokenCount: 0,
542
+ cacheReadTokenCount: 0,
543
+ durationMs: num(value.durationMs),
544
+ };
545
+ }
546
+
547
+ /** Map one engine `AgentInstanceMetrics` (the instance-level aggregate) onto
548
+ * {@link AgentInstanceMetrics}, or `undefined` when the row carries none. */
549
+ function mapAgentInstanceMetrics(value: unknown): AgentInstanceMetrics | undefined {
550
+ if (!isRecord(value)) return undefined;
551
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
552
+ return {
553
+ inputTokens: num(value.inputTokens),
554
+ outputTokens: num(value.outputTokens),
555
+ modelCalls: num(value.modelCalls),
556
+ toolCalls: num(value.toolCalls),
557
+ };
558
+ }
559
+
560
+ /** Map one engine `AgentInstanceResult` row onto an {@link AgentInstanceSummary}, or `undefined`
561
+ * when the row is malformed (missing the `agentInstanceKey` or owning `processInstanceKey`). The
562
+ * single source of truth `searchAgentInstances`/`getAgentInstance` extract through, so the two
563
+ * cannot drift. Optional linkage/metrics/timestamp fields are emitted only when the engine
564
+ * reports them. */
565
+ export function mapAgentInstanceRow(
566
+ row: Record<string, unknown>,
567
+ log: Log,
568
+ ): AgentInstanceSummary | undefined {
569
+ const agentInstanceKey = presentEngineKey(row.agentInstanceKey);
570
+ if (agentInstanceKey === undefined) {
571
+ log("warn", "skipping agent instance with no agentInstanceKey in engine response");
572
+ return undefined;
573
+ }
574
+ const processInstanceKey = presentEngineKey(row.processInstanceKey);
575
+ if (processInstanceKey === undefined) {
576
+ log("warn", "skipping agent instance with no processInstanceKey in engine response", {
577
+ agentInstanceKey,
578
+ });
579
+ return undefined;
580
+ }
581
+ const status = presentText(row.status) ?? "UNKNOWN";
582
+ const elementId = presentText(row.elementId);
583
+ const rootProcessInstanceKey = presentEngineKey(row.rootProcessInstanceKey);
584
+ const processDefinitionKey = presentEngineKey(row.processDefinitionKey);
585
+ const processDefinitionId = presentText(row.processDefinitionId);
586
+ const elementInstanceKeys = Array.isArray(row.elementInstanceKeys)
587
+ ? row.elementInstanceKeys
588
+ .map((k) => presentEngineKey(k))
589
+ .filter((k): k is string => k !== undefined)
590
+ : undefined;
591
+ const metrics = mapAgentInstanceMetrics(row.metrics);
592
+ const creationDate = presentText(row.creationDate);
593
+ const lastUpdatedDate = presentText(row.lastUpdatedDate);
594
+ const completionDate = presentText(row.completionDate);
595
+ return {
596
+ agentInstanceKey,
597
+ status,
598
+ processInstanceKey,
599
+ ...(elementId ? { elementId } : {}),
600
+ ...(elementInstanceKeys && elementInstanceKeys.length > 0 ? { elementInstanceKeys } : {}),
601
+ ...(rootProcessInstanceKey ? { rootProcessInstanceKey } : {}),
602
+ ...(processDefinitionKey ? { processDefinitionKey } : {}),
603
+ ...(processDefinitionId ? { processDefinitionId } : {}),
604
+ ...(metrics ? { metrics } : {}),
605
+ ...(creationDate ? { creationDate } : {}),
606
+ ...(lastUpdatedDate ? { lastUpdatedDate } : {}),
607
+ ...(completionDate ? { completionDate } : {}),
608
+ };
609
+ }
610
+
611
+ /** Map one engine `AgentInstanceHistoryItemResult` row onto an {@link AgentHistoryRecord}, or
612
+ * `undefined` when the row is malformed (missing the `historyItemKey` or owning
613
+ * `agentInstanceKey`). `content`/`toolCalls` drop any individually-malformed block; `loopIteration`
614
+ * defaults to `0` when unreported; `commitStatus` passes through as a bare string. The single
615
+ * source of truth `searchAgentInstanceHistory` extracts through. */
616
+ export function mapAgentHistoryRow(
617
+ row: Record<string, unknown>,
618
+ log: Log,
619
+ ): AgentHistoryRecord | undefined {
620
+ const historyItemKey = presentEngineKey(row.historyItemKey);
621
+ if (historyItemKey === undefined) {
622
+ log("warn", "skipping agent history item with no historyItemKey in engine response");
623
+ return undefined;
624
+ }
625
+ const agentInstanceKey = presentEngineKey(row.agentInstanceKey);
626
+ if (agentInstanceKey === undefined) {
627
+ log("warn", "skipping agent history item with no agentInstanceKey in engine response", {
628
+ historyItemKey,
629
+ });
630
+ return undefined;
631
+ }
632
+ const loopIteration =
633
+ typeof row.loopIteration === "number" && Number.isFinite(row.loopIteration)
634
+ ? row.loopIteration
635
+ : 0;
636
+ const content = Array.isArray(row.content)
637
+ ? row.content.flatMap((c) => {
638
+ const block = mapAgentContentBlock(c);
639
+ return block ? [block] : [];
640
+ })
641
+ : [];
642
+ const toolCalls = Array.isArray(row.toolCalls)
643
+ ? row.toolCalls.flatMap((t) => {
644
+ const call = mapAgentToolCall(t);
645
+ return call ? [call] : [];
646
+ })
647
+ : [];
648
+ const metrics = mapAgentHistoryMetrics(row.metrics);
649
+ const commitStatus = presentText(row.commitStatus) ?? "COMMITTED";
650
+ const elementInstanceKey = presentEngineKey(row.elementInstanceKey);
651
+ const jobKey = presentEngineKey(row.jobKey);
652
+ const producedAt = presentText(row.producedAt);
653
+ return {
654
+ historyItemKey,
655
+ agentInstanceKey,
656
+ loopIteration,
657
+ role: normalizeAgentHistoryRole(row.role),
658
+ content,
659
+ toolCalls,
660
+ ...(metrics ? { metrics } : {}),
661
+ commitStatus,
662
+ ...(elementInstanceKey ? { elementInstanceKey } : {}),
663
+ ...(jobKey ? { jobKey } : {}),
664
+ ...(producedAt ? { producedAt } : {}),
665
+ };
666
+ }
667
+
460
668
  /** A job as delivered to a nano-sdk job handler: the frame fields plus the
461
669
  * acknowledgement actions the handler must call. */
462
670
  export interface NanoSdkActivatedJob {
@@ -575,6 +783,21 @@ export interface NanoSdkClient {
575
783
  consistency?: unknown,
576
784
  options?: unknown,
577
785
  ): Promise<unknown>;
786
+ searchAgentInstances(
787
+ input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
788
+ consistency?: unknown,
789
+ options?: unknown,
790
+ ): Promise<Record<string, unknown>>;
791
+ searchAgentInstanceHistory(
792
+ input: { agentInstanceKey: string; filter?: Record<string, unknown>; page?: Record<string, unknown> },
793
+ consistency?: unknown,
794
+ options?: unknown,
795
+ ): Promise<Record<string, unknown>>;
796
+ getAgentInstance(
797
+ input: { agentInstanceKey: string },
798
+ consistency?: unknown,
799
+ options?: unknown,
800
+ ): Promise<Record<string, unknown>>;
578
801
  resolveIncident(
579
802
  input: { incidentKey: string },
580
803
  options?: unknown,
@@ -1018,6 +1241,88 @@ export class SdkEngineClient implements EngineClient {
1018
1241
  return typeof xml === "string" && xml.trim() !== "" ? xml : null;
1019
1242
  }
1020
1243
 
1244
+ async searchAgentInstances(filter?: AgentInstanceFilter): Promise<AgentInstanceSummary[]> {
1245
+ // Same shape as `searchIncidents`: build the engine filter from the transport-agnostic
1246
+ // selectors, read at zero-wait consistency (an agent-instance search is eventually
1247
+ // consistent), and map each row through the single `mapAgentInstanceRow` gate, dropping
1248
+ // malformed rows. Normalize the request key selectors the same way the response mapper
1249
+ // normalizes keys so a blank/padded selector cannot reach the engine as a garbage filter.
1250
+ const f: Record<string, unknown> = {};
1251
+ const processInstanceKey = presentEngineKey(filter?.processInstanceKey);
1252
+ if (processInstanceKey) f.processInstanceKey = processInstanceKey;
1253
+ const rootProcessInstanceKey = presentEngineKey(filter?.rootProcessInstanceKey);
1254
+ if (rootProcessInstanceKey) f.rootProcessInstanceKey = rootProcessInstanceKey;
1255
+ const status = filter?.status?.trim();
1256
+ if (status) f.status = status;
1257
+ const elementId = filter?.elementId?.trim();
1258
+ if (elementId) f.elementId = elementId;
1259
+ const body = await this.client.searchAgentInstances(
1260
+ { filter: f },
1261
+ { consistency: { waitUpToMs: 0 } },
1262
+ );
1263
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
1264
+ return items.flatMap((it) => {
1265
+ const mapped = mapAgentInstanceRow(it, this.log);
1266
+ return mapped ? [mapped] : [];
1267
+ });
1268
+ }
1269
+
1270
+ async searchAgentInstanceHistory(
1271
+ agentInstanceKey: string,
1272
+ filter?: AgentHistoryFilter,
1273
+ ): Promise<AgentHistoryRecord[]> {
1274
+ // A blank key can never address an agent instance — short-circuit to the empty list rather
1275
+ // than issue a `POST /agent-instances//history/search` with an empty path segment. Normalize a
1276
+ // padded-but-valid key so `" 5 "` addresses the same instance, mirroring `getElementInstance`.
1277
+ const key = presentEngineKey(agentInstanceKey);
1278
+ if (key === undefined) return [];
1279
+ const f: Record<string, unknown> = {};
1280
+ // `AgentHistoryFilter.role` is typed as the wider transcript `TranscriptTurnRole` union, which
1281
+ // also carries `CONFIGURATION`/`UNSPECIFIED` — roles the engine's history filter does not
1282
+ // accept and would 4xx on. Gate the selector through the same `AGENT_HISTORY_ROLES` subset the
1283
+ // response mapper uses (No Drift Surfaces) so an unsupported role is dropped, not forwarded.
1284
+ if (filter?.role && AGENT_HISTORY_ROLES.includes(filter.role)) f.role = filter.role;
1285
+ if (typeof filter?.loopIteration === "number" && Number.isFinite(filter.loopIteration)) {
1286
+ f.loopIteration = filter.loopIteration;
1287
+ }
1288
+ const elementInstanceKey = presentEngineKey(filter?.elementInstanceKey);
1289
+ if (elementInstanceKey) f.elementInstanceKey = elementInstanceKey;
1290
+ const body = await this.client.searchAgentInstanceHistory(
1291
+ { agentInstanceKey: key, filter: f },
1292
+ { consistency: { waitUpToMs: 0 } },
1293
+ );
1294
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
1295
+ return items.flatMap((it) => {
1296
+ const mapped = mapAgentHistoryRow(it, this.log);
1297
+ return mapped ? [mapped] : [];
1298
+ });
1299
+ }
1300
+
1301
+ async getAgentInstance(agentInstanceKey: string): Promise<AgentInstanceSummary | null> {
1302
+ // A blank key can never address an agent instance — short-circuit to null rather than issue a
1303
+ // `GET /agent-instances/` with an empty segment. Normalize a padded-but-valid key, mirroring
1304
+ // `getElementInstance`/`getProcessDefinitionXml`.
1305
+ const key = presentEngineKey(agentInstanceKey);
1306
+ if (key === undefined) return null;
1307
+ let body: Record<string, unknown>;
1308
+ try {
1309
+ body = await this.client.getAgentInstance(
1310
+ { agentInstanceKey: key },
1311
+ { consistency: { waitUpToMs: 0 } },
1312
+ );
1313
+ } catch (err) {
1314
+ // A 404 (no such agent instance) is an expected "not found", not a fault — mirror `getForm`/
1315
+ // `getElementInstance`, which treat a failed fetch as absence (null) rather than propagating.
1316
+ this.log("warn", "getAgentInstance: engine fetch failed", {
1317
+ agentInstanceKey: key,
1318
+ err: err instanceof Error ? err.message : String(err),
1319
+ });
1320
+ return null;
1321
+ }
1322
+ if (!isRecord(body)) return null;
1323
+ return mapAgentInstanceRow(body, this.log) ?? null;
1324
+ }
1325
+
1021
1326
  async resolveIncident(input: { incidentKey: string }): Promise<void> {
1022
1327
  await this.client.resolveIncident({
1023
1328
  incidentKey: requireEngineKey(input.incidentKey, "incidentKey"),
@@ -83,6 +83,11 @@ export type { ValidationIssue } from "./core/validate.ts";
83
83
  // Host + engine contracts (for custom hosts / tests)
84
84
  export type {
85
85
  EngineClient,
86
+ AgentHistoryFilter,
87
+ AgentHistoryRecord,
88
+ AgentInstanceFilter,
89
+ AgentInstanceMetrics,
90
+ AgentInstanceSummary,
86
91
  ElementInstanceState,
87
92
  ElementInstanceSummary,
88
93
  ElementInstanceFilter,
@@ -114,6 +119,17 @@ export type {
114
119
  WatchHandle,
115
120
  WorkerSubscription,
116
121
  } from "./core/host.ts";
122
+ // The Camunda `AgentHistoryRecordValue`-parity conversation grammar an {@link AgentHistoryRecord}
123
+ // reuses, re-exported from `@nanobpm/agentic/transcript` so a consumer naming an
124
+ // `AgentHistoryRecord`'s `role`/`content`/`toolCalls`/`metrics` reaches the SAME types the
125
+ // transcript store models rather than a forked projection (No Drift Surfaces).
126
+ export type {
127
+ TranscriptContentBlock,
128
+ TranscriptContentType,
129
+ TranscriptToolCall,
130
+ TranscriptTurnMetrics,
131
+ TranscriptTurnRole,
132
+ } from "@nanobpm/agentic/transcript";
117
133
  // Shared form/user-task contract normalization (issue #252): the single source of truth
118
134
  // both engine adapters (SDK/REST + WASM test double) call, so their form-identifier,
119
135
  // form-schema, and user-task form-linkage handling cannot drift.