@linxiraos/pi-agent-core 1.1.19 → 1.1.22
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 +253 -10
- package/README.md +4 -3
- package/THIRD-PARTY-NOTICES.txt +3 -40
- package/dist/types/agent-loop.d.ts +14 -1
- package/dist/types/agent.d.ts +36 -0
- package/dist/types/compaction/anthropic.d.ts +22 -52
- package/dist/types/compaction/branch-summarization.d.ts +1 -1
- package/dist/types/compaction/compaction.d.ts +4 -2
- package/dist/types/compaction/messages.d.ts +9 -0
- package/dist/types/compaction/openai.d.ts +11 -0
- package/dist/types/compaction/pruning.d.ts +9 -0
- package/dist/types/compaction/transcript-tokens.d.ts +25 -2
- package/dist/types/index.d.ts +3 -0
- package/dist/types/live-steering.d.ts +34 -0
- package/dist/types/output-budget.d.ts +54 -0
- package/dist/types/sent-tool-definitions.d.ts +17 -0
- package/dist/types/speculative-execution.d.ts +2 -1
- package/dist/types/telemetry.d.ts +103 -60
- package/dist/types/tool-context.d.ts +32 -0
- package/dist/types/types.d.ts +105 -7
- package/package.json +10 -10
- package/src/agent-loop.ts +386 -90
- package/src/agent.ts +175 -26
- package/src/compaction/anthropic.ts +62 -105
- package/src/compaction/branch-summarization.ts +1 -1
- package/src/compaction/compaction-v2-streaming.ts +13 -4
- package/src/compaction/compaction.ts +63 -51
- package/src/compaction/messages.ts +12 -2
- package/src/compaction/openai.ts +37 -9
- package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
- package/src/compaction/pruning.ts +1 -1
- package/src/compaction/transcript-tokens.ts +64 -3
- package/src/index.ts +6 -0
- package/src/live-steering.ts +89 -0
- package/src/output-budget.ts +142 -0
- package/src/sent-tool-definitions.ts +40 -0
- package/src/speculative-execution.ts +12 -0
- package/src/telemetry.ts +240 -117
- package/src/tokenizer.ts +45 -6
- package/src/tool-context.ts +55 -0
- package/src/types.ts +109 -7
package/src/telemetry.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* OpenTelemetry instrumentation for the agent loop.
|
|
3
3
|
*
|
|
4
4
|
* Implements the OpenTelemetry GenAI semantic conventions
|
|
5
|
-
* (https://opentelemetry.io/docs/specs/semconv/gen-ai/) plus `
|
|
5
|
+
* (https://opentelemetry.io/docs/specs/semconv/gen-ai/) plus `omp.gen_ai.*`
|
|
6
6
|
* extension attributes for run summaries, dashboard summaries, and cost hints
|
|
7
7
|
* that are useful to downstream observability UIs.
|
|
8
8
|
*
|
|
@@ -125,42 +125,48 @@ export const enum OpenAIAttr {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
/** Project extension attributes. Kept out of the reserved `gen_ai.*` namespace. */
|
|
128
|
-
export const enum
|
|
129
|
-
AgentStepNumber = "
|
|
130
|
-
AgentStepCount = "
|
|
131
|
-
RequestReasoningEffort = "
|
|
132
|
-
RequestToolChoice = "
|
|
133
|
-
RequestAvailableTools = "
|
|
134
|
-
RequestMessages = "
|
|
135
|
-
ResponseText = "
|
|
136
|
-
ResponseToolCalls = "
|
|
137
|
-
ResponseUpstreamProvider = "
|
|
138
|
-
UsageTotalTokens = "
|
|
139
|
-
UsageServerSideTools = "
|
|
140
|
-
CostEstimatedUsd = "
|
|
141
|
-
CostInputUsd = "
|
|
142
|
-
CostOutputUsd = "
|
|
143
|
-
CostUnavailableReason = "
|
|
144
|
-
ToolStatus = "
|
|
145
|
-
ToolCallIntent = "
|
|
146
|
-
HandoffFromAgentName = "
|
|
147
|
-
HandoffFromAgentId = "
|
|
148
|
-
HandoffToAgentName = "
|
|
149
|
-
HandoffToAgentId = "
|
|
128
|
+
export const enum OmpGenAIAttr {
|
|
129
|
+
AgentStepNumber = "omp.gen_ai.agent.step.number",
|
|
130
|
+
AgentStepCount = "omp.gen_ai.agent.step.count",
|
|
131
|
+
RequestReasoningEffort = "omp.gen_ai.request.reasoning.effort",
|
|
132
|
+
RequestToolChoice = "omp.gen_ai.request.tool.choice",
|
|
133
|
+
RequestAvailableTools = "omp.gen_ai.request.available_tools",
|
|
134
|
+
RequestMessages = "omp.gen_ai.request.messages",
|
|
135
|
+
ResponseText = "omp.gen_ai.response.text",
|
|
136
|
+
ResponseToolCalls = "omp.gen_ai.response.tool_calls",
|
|
137
|
+
ResponseUpstreamProvider = "omp.gen_ai.response.upstream_provider",
|
|
138
|
+
UsageTotalTokens = "omp.gen_ai.usage.total_tokens",
|
|
139
|
+
UsageServerSideTools = "omp.gen_ai.usage.server_tool_requests",
|
|
140
|
+
CostEstimatedUsd = "omp.gen_ai.cost.estimated_usd",
|
|
141
|
+
CostInputUsd = "omp.gen_ai.cost.input_usd",
|
|
142
|
+
CostOutputUsd = "omp.gen_ai.cost.output_usd",
|
|
143
|
+
CostUnavailableReason = "omp.gen_ai.cost.unavailable_reason",
|
|
144
|
+
ToolStatus = "omp.gen_ai.tool.status",
|
|
145
|
+
ToolCallIntent = "omp.gen_ai.tool.call.intent",
|
|
146
|
+
HandoffFromAgentName = "omp.gen_ai.handoff.from_agent.name",
|
|
147
|
+
HandoffFromAgentId = "omp.gen_ai.handoff.from_agent.id",
|
|
148
|
+
HandoffToAgentName = "omp.gen_ai.handoff.to_agent.name",
|
|
149
|
+
HandoffToAgentId = "omp.gen_ai.handoff.to_agent.id",
|
|
150
150
|
// Marks chat spans emitted outside the agent loop (compaction, handoff, branch
|
|
151
151
|
// summary, image inspection, …). Lets dashboards split oneshot cost / latency
|
|
152
152
|
// from main-turn cost without overloading the semconv `gen_ai.operation.name`.
|
|
153
|
-
OneshotKind = "
|
|
153
|
+
OneshotKind = "omp.gen_ai.oneshot.kind",
|
|
154
154
|
// Gateway / proxy (LiteLLM, Helicone, Portkey, …) — populated when a known
|
|
155
155
|
// gateway header pattern is detected on the upstream response. The base
|
|
156
156
|
// `gen_ai.provider.name` continues to track the *upstream* provider (e.g.
|
|
157
157
|
// `anthropic`) that the gateway routed to.
|
|
158
|
-
GatewayName = "
|
|
159
|
-
GatewayEndpoint = "
|
|
160
|
-
GatewayCallId = "
|
|
161
|
-
GatewayRoutedTo = "
|
|
158
|
+
GatewayName = "omp.gen_ai.gateway.name",
|
|
159
|
+
GatewayEndpoint = "omp.gen_ai.gateway.endpoint",
|
|
160
|
+
GatewayCallId = "omp.gen_ai.gateway.call_id",
|
|
161
|
+
GatewayRoutedTo = "omp.gen_ai.gateway.routed_to",
|
|
162
162
|
/** Cloudflare AI Gateway response-cache status (`cf-aig-cache-status`), never prompt-cache. */
|
|
163
|
-
GatewayResponseCacheStatus = "
|
|
163
|
+
GatewayResponseCacheStatus = "omp.gen_ai.gateway.response_cache.status",
|
|
164
|
+
/** Caller-level reason a judgment ran (`find`, `ttsr`, `judge_batch`, …). */
|
|
165
|
+
JudgmentPurpose = "omp.gen_ai.judgment.purpose",
|
|
166
|
+
/** Questions the caller asked in one judgment request. */
|
|
167
|
+
JudgmentQuestions = "omp.gen_ai.judgment.questions",
|
|
168
|
+
/** Questions answered from the local judgment cache instead of the provider. */
|
|
169
|
+
JudgmentCachedQuestions = "omp.gen_ai.judgment.cached_questions",
|
|
164
170
|
}
|
|
165
171
|
|
|
166
172
|
/** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
|
|
@@ -169,6 +175,8 @@ export const GenAIOperation = {
|
|
|
169
175
|
ExecuteTool: "execute_tool",
|
|
170
176
|
InvokeAgent: "invoke_agent",
|
|
171
177
|
Handoff: "handoff",
|
|
178
|
+
/** Typed judgment over a state (System One or a prompted judge model); a project extension value. */
|
|
179
|
+
Judgment: "judgment",
|
|
172
180
|
GenerateContent: "generate_content",
|
|
173
181
|
TextCompletion: "text_completion",
|
|
174
182
|
CreateAgent: "create_agent",
|
|
@@ -178,7 +186,7 @@ export const GenAIOperation = {
|
|
|
178
186
|
export type GenAIOperationName = (typeof GenAIOperation)[keyof typeof GenAIOperation];
|
|
179
187
|
|
|
180
188
|
/** Identifies which agent span a callback is reporting on. */
|
|
181
|
-
export type TelemetrySpanKind = "invoke_agent" | "chat" | "execute_tool" | "handoff";
|
|
189
|
+
export type TelemetrySpanKind = "invoke_agent" | "chat" | "execute_tool" | "handoff" | "judgment";
|
|
182
190
|
|
|
183
191
|
/**
|
|
184
192
|
* Aggregated usage + cost surface passed to {@link AgentTelemetryConfig.costEstimator}.
|
|
@@ -204,9 +212,9 @@ export interface CostEstimatorContext {
|
|
|
204
212
|
|
|
205
213
|
/**
|
|
206
214
|
* Cost estimator result.
|
|
207
|
-
* { usd: number } — cost is known; emitted as
|
|
215
|
+
* { usd: number } — cost is known; emitted as omp.gen_ai.cost.estimated_usd
|
|
208
216
|
* { unavailable: string } — cost is intentionally unknown; emitted as
|
|
209
|
-
*
|
|
217
|
+
* omp.gen_ai.cost.unavailable_reason
|
|
210
218
|
* undefined — no opinion; nothing emitted
|
|
211
219
|
*/
|
|
212
220
|
export type CostEstimate =
|
|
@@ -235,6 +243,8 @@ export interface CostDelta {
|
|
|
235
243
|
*/
|
|
236
244
|
export interface ChatUsageEvent {
|
|
237
245
|
readonly span: Span;
|
|
246
|
+
/** Operation that produced the usage: `chat` for model turns, `judgment` for typed judgments. */
|
|
247
|
+
readonly operation: GenAIOperationName;
|
|
238
248
|
readonly agent: AgentIdentity | undefined;
|
|
239
249
|
readonly conversationId: string | undefined;
|
|
240
250
|
readonly stepNumber: number | undefined;
|
|
@@ -253,7 +263,7 @@ export interface ChatUsageEvent {
|
|
|
253
263
|
*
|
|
254
264
|
* Use this to reconcile gateway-issued ids (e.g. `x-litellm-call-id`) with
|
|
255
265
|
* downstream billing / spend dashboards. Known gateway patterns are also
|
|
256
|
-
* auto-stamped on the chat span as `
|
|
266
|
+
* auto-stamped on the chat span as `omp.gen_ai.gateway.*` attributes.
|
|
257
267
|
*/
|
|
258
268
|
readonly headers: Readonly<Record<string, string>> | undefined;
|
|
259
269
|
}
|
|
@@ -475,6 +485,8 @@ function startSpan(
|
|
|
475
485
|
readonly stepNumber?: number;
|
|
476
486
|
readonly toolCallId?: string;
|
|
477
487
|
readonly toolName?: string;
|
|
488
|
+
/** Epoch ms; defaults to now. */
|
|
489
|
+
readonly startTime?: number;
|
|
478
490
|
},
|
|
479
491
|
): Span | undefined {
|
|
480
492
|
if (!telemetry) return undefined;
|
|
@@ -497,7 +509,11 @@ function startSpan(
|
|
|
497
509
|
if (options.attributes) Object.assign(attrs, options.attributes);
|
|
498
510
|
|
|
499
511
|
const ctx = options.parent ? trace.setSpan(context.active(), options.parent) : context.active();
|
|
500
|
-
const span = telemetry.tracer.startSpan(
|
|
512
|
+
const span = telemetry.tracer.startSpan(
|
|
513
|
+
name,
|
|
514
|
+
{ kind: options.spanKind, attributes: attrs, startTime: options.startTime },
|
|
515
|
+
ctx,
|
|
516
|
+
);
|
|
501
517
|
safeOnSpanStart(telemetry, { ...attrCtx, span });
|
|
502
518
|
return span;
|
|
503
519
|
}
|
|
@@ -548,6 +564,8 @@ function kindToOperation(kind: TelemetrySpanKind): GenAIOperationName | undefine
|
|
|
548
564
|
return GenAIOperation.ExecuteTool;
|
|
549
565
|
case "handoff":
|
|
550
566
|
return GenAIOperation.Handoff;
|
|
567
|
+
case "judgment":
|
|
568
|
+
return GenAIOperation.Judgment;
|
|
551
569
|
}
|
|
552
570
|
}
|
|
553
571
|
|
|
@@ -683,7 +701,7 @@ export function startInvokeAgentSpan(telemetry: AgentTelemetry | undefined, mode
|
|
|
683
701
|
/** Stamp the final step count on the `invoke_agent` span. */
|
|
684
702
|
export function applyInvokeAgentFinish(span: Span | undefined, stepCount: number): void {
|
|
685
703
|
if (!span) return;
|
|
686
|
-
span.setAttribute(
|
|
704
|
+
span.setAttribute(OmpGenAIAttr.AgentStepCount, stepCount);
|
|
687
705
|
}
|
|
688
706
|
|
|
689
707
|
/**
|
|
@@ -740,7 +758,7 @@ export interface ChatRequestSnapshot {
|
|
|
740
758
|
|
|
741
759
|
function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnapshot, provider: string): Attributes {
|
|
742
760
|
const attrs: Attributes = {
|
|
743
|
-
[
|
|
761
|
+
[OmpGenAIAttr.AgentStepNumber]: stepNumber,
|
|
744
762
|
[GenAIAttr.OutputType]: "text",
|
|
745
763
|
[GenAIAttr.RequestStream]: true,
|
|
746
764
|
};
|
|
@@ -757,11 +775,11 @@ function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnap
|
|
|
757
775
|
if (request.serviceTier && shouldSendServiceTier(request.serviceTier, provider)) {
|
|
758
776
|
attrs[OpenAIAttr.RequestServiceTier] = request.serviceTier;
|
|
759
777
|
}
|
|
760
|
-
if (request.reasoningEffort) attrs[
|
|
778
|
+
if (request.reasoningEffort) attrs[OmpGenAIAttr.RequestReasoningEffort] = request.reasoningEffort;
|
|
761
779
|
const toolChoice = serializeToolChoice(request.toolChoice);
|
|
762
|
-
if (toolChoice) attrs[
|
|
780
|
+
if (toolChoice) attrs[OmpGenAIAttr.RequestToolChoice] = toolChoice;
|
|
763
781
|
if (request.tools && request.tools.length > 0) {
|
|
764
|
-
attrs[
|
|
782
|
+
attrs[OmpGenAIAttr.RequestAvailableTools] = request.tools.map(tool => tool.name);
|
|
765
783
|
}
|
|
766
784
|
return attrs;
|
|
767
785
|
}
|
|
@@ -779,7 +797,7 @@ function serializeToolChoice(toolChoice: ToolChoice | undefined): string | undef
|
|
|
779
797
|
|
|
780
798
|
function applyContentCaptureForRequest(telemetry: AgentTelemetry, span: Span, request: ChatRequestSnapshot): void {
|
|
781
799
|
const requestMessages = serializeRequestMessagesForTelemetry(telemetry, request);
|
|
782
|
-
if (requestMessages) span.setAttribute(
|
|
800
|
+
if (requestMessages) span.setAttribute(OmpGenAIAttr.RequestMessages, requestMessages);
|
|
783
801
|
if (telemetry.contentCapture !== "full") return;
|
|
784
802
|
const systemInstructions = serializeFullSystemInstructionsForTelemetry(request);
|
|
785
803
|
if (systemInstructions) span.setAttribute(GenAIAttr.SystemInstructions, systemInstructions);
|
|
@@ -789,9 +807,9 @@ function applyContentCaptureForRequest(telemetry: AgentTelemetry, span: Span, re
|
|
|
789
807
|
|
|
790
808
|
function applyContentCaptureForResponse(telemetry: AgentTelemetry, span: Span, message: AssistantMessage): void {
|
|
791
809
|
const responseText = serializeResponseTextForTelemetry(telemetry, message);
|
|
792
|
-
if (responseText) span.setAttribute(
|
|
810
|
+
if (responseText) span.setAttribute(OmpGenAIAttr.ResponseText, responseText);
|
|
793
811
|
const responseToolCalls = serializeResponseToolCallsForTelemetry(telemetry, message);
|
|
794
|
-
if (responseToolCalls) span.setAttribute(
|
|
812
|
+
if (responseToolCalls) span.setAttribute(OmpGenAIAttr.ResponseToolCalls, responseToolCalls);
|
|
795
813
|
if (telemetry.contentCapture === "full") {
|
|
796
814
|
const outputMessages = serializeFullOutputMessagesForTelemetry(message);
|
|
797
815
|
if (outputMessages) span.setAttribute(GenAIAttr.OutputMessages, outputMessages);
|
|
@@ -1131,6 +1149,7 @@ export async function finishChatSpan(
|
|
|
1131
1149
|
const cost = applyCostEstimate(telemetry, span, message, options.serviceTier, options.stepNumber);
|
|
1132
1150
|
if (telemetry) {
|
|
1133
1151
|
await emitChatUsage(telemetry, span, {
|
|
1152
|
+
operation: GenAIOperation.Chat,
|
|
1134
1153
|
model: message.model,
|
|
1135
1154
|
provider: message.provider,
|
|
1136
1155
|
serviceTier: options.serviceTier,
|
|
@@ -1199,7 +1218,7 @@ function applyChatResponseAttributes(span: Span, message: AssistantMessage): voi
|
|
|
1199
1218
|
span.setAttribute(GenAIAttr.ResponseModel, message.model);
|
|
1200
1219
|
if (message.responseId) span.setAttribute(GenAIAttr.ResponseId, message.responseId);
|
|
1201
1220
|
if (message.upstreamProvider) {
|
|
1202
|
-
span.setAttribute(
|
|
1221
|
+
span.setAttribute(OmpGenAIAttr.ResponseUpstreamProvider, message.upstreamProvider);
|
|
1203
1222
|
}
|
|
1204
1223
|
if (message.ttft != null) span.setAttribute(GenAIAttr.ResponseTimeToFirstChunk, message.ttft / 1000);
|
|
1205
1224
|
const finishReason = mapStopReason(message.stopReason);
|
|
@@ -1215,7 +1234,7 @@ function applyUsageAttributes(span: Span, usage: Usage | undefined): void {
|
|
|
1215
1234
|
span.setAttribute(GenAIAttr.UsageInputTokens, inputTokens);
|
|
1216
1235
|
span.setAttribute(GenAIAttr.UsageOutputTokens, outputTokens);
|
|
1217
1236
|
const total = usage.totalTokens ?? inputTokens + outputTokens;
|
|
1218
|
-
span.setAttribute(
|
|
1237
|
+
span.setAttribute(OmpGenAIAttr.UsageTotalTokens, total);
|
|
1219
1238
|
if (usage.cacheRead != null) span.setAttribute(GenAIAttr.UsageCacheReadInputTokens, usage.cacheRead);
|
|
1220
1239
|
if (usage.cacheWrite != null) span.setAttribute(GenAIAttr.UsageCacheCreationInputTokens, usage.cacheWrite);
|
|
1221
1240
|
if (usage.reasoningTokens != null) {
|
|
@@ -1223,7 +1242,7 @@ function applyUsageAttributes(span: Span, usage: Usage | undefined): void {
|
|
|
1223
1242
|
}
|
|
1224
1243
|
if (usage.server) {
|
|
1225
1244
|
const sums = (usage.server.webSearch ?? 0) + (usage.server.webFetch ?? 0);
|
|
1226
|
-
if (sums > 0) span.setAttribute(
|
|
1245
|
+
if (sums > 0) span.setAttribute(OmpGenAIAttr.UsageServerSideTools, sums);
|
|
1227
1246
|
}
|
|
1228
1247
|
}
|
|
1229
1248
|
|
|
@@ -1284,7 +1303,7 @@ export function detectGatewayFromHeaders(
|
|
|
1284
1303
|
|
|
1285
1304
|
/**
|
|
1286
1305
|
* Bounded Cloudflare AI Gateway response-cache statuses emitted on
|
|
1287
|
-
* {@link
|
|
1306
|
+
* {@link OmpGenAIAttr.GatewayResponseCacheStatus}. Distinct from provider
|
|
1288
1307
|
* prompt-cache token counters (`gen_ai.usage.cache_*`).
|
|
1289
1308
|
*
|
|
1290
1309
|
* Cloudflare documents `HIT` / `MISS` on `cf-aig-cache-status`; `bypass` covers
|
|
@@ -1323,14 +1342,14 @@ function applyGatewayAttributes(
|
|
|
1323
1342
|
): void {
|
|
1324
1343
|
const gateway = detectGatewayFromHeaders(headers);
|
|
1325
1344
|
if (gateway) {
|
|
1326
|
-
span.setAttribute(
|
|
1327
|
-
if (baseUrl) span.setAttribute(
|
|
1328
|
-
if (gateway.callId) span.setAttribute(
|
|
1329
|
-
if (gateway.routedTo) span.setAttribute(
|
|
1345
|
+
span.setAttribute(OmpGenAIAttr.GatewayName, gateway.name);
|
|
1346
|
+
if (baseUrl) span.setAttribute(OmpGenAIAttr.GatewayEndpoint, baseUrl);
|
|
1347
|
+
if (gateway.callId) span.setAttribute(OmpGenAIAttr.GatewayCallId, gateway.callId);
|
|
1348
|
+
if (gateway.routedTo) span.setAttribute(OmpGenAIAttr.GatewayRoutedTo, gateway.routedTo);
|
|
1330
1349
|
}
|
|
1331
1350
|
const responseCacheStatus = classifyGatewayResponseCacheStatus(headers);
|
|
1332
1351
|
if (responseCacheStatus) {
|
|
1333
|
-
span.setAttribute(
|
|
1352
|
+
span.setAttribute(OmpGenAIAttr.GatewayResponseCacheStatus, responseCacheStatus);
|
|
1334
1353
|
}
|
|
1335
1354
|
}
|
|
1336
1355
|
|
|
@@ -1392,7 +1411,7 @@ function applyCostEstimateForUsage(
|
|
|
1392
1411
|
}
|
|
1393
1412
|
if (!result) return EMPTY_COST;
|
|
1394
1413
|
if ("unavailable" in result) {
|
|
1395
|
-
span.setAttribute(
|
|
1414
|
+
span.setAttribute(OmpGenAIAttr.CostUnavailableReason, result.unavailable);
|
|
1396
1415
|
const cost: AppliedCostEstimate = {
|
|
1397
1416
|
costUsd: undefined,
|
|
1398
1417
|
inputUsd: undefined,
|
|
@@ -1414,9 +1433,9 @@ function applyCostEstimateForUsage(
|
|
|
1414
1433
|
});
|
|
1415
1434
|
return cost;
|
|
1416
1435
|
}
|
|
1417
|
-
span.setAttribute(
|
|
1418
|
-
if (result.inputUsd != null) span.setAttribute(
|
|
1419
|
-
if (result.outputUsd != null) span.setAttribute(
|
|
1436
|
+
span.setAttribute(OmpGenAIAttr.CostEstimatedUsd, result.usd);
|
|
1437
|
+
if (result.inputUsd != null) span.setAttribute(OmpGenAIAttr.CostInputUsd, result.inputUsd);
|
|
1438
|
+
if (result.outputUsd != null) span.setAttribute(OmpGenAIAttr.CostOutputUsd, result.outputUsd);
|
|
1420
1439
|
const cost: AppliedCostEstimate = {
|
|
1421
1440
|
costUsd: result.usd,
|
|
1422
1441
|
inputUsd: result.inputUsd,
|
|
@@ -1470,6 +1489,7 @@ async function emitChatUsage(
|
|
|
1470
1489
|
telemetry: AgentTelemetry,
|
|
1471
1490
|
span: Span,
|
|
1472
1491
|
input: {
|
|
1492
|
+
readonly operation: GenAIOperationName;
|
|
1473
1493
|
readonly model: string;
|
|
1474
1494
|
readonly provider: string | undefined;
|
|
1475
1495
|
readonly serviceTier: ServiceTier | undefined;
|
|
@@ -1483,6 +1503,7 @@ async function emitChatUsage(
|
|
|
1483
1503
|
if (!hook || !input.usage) return;
|
|
1484
1504
|
const event: ChatUsageEvent = {
|
|
1485
1505
|
span,
|
|
1506
|
+
operation: input.operation,
|
|
1486
1507
|
agent: normalizedTelemetryAgent(telemetry),
|
|
1487
1508
|
conversationId: telemetry.conversationId,
|
|
1488
1509
|
stepNumber: input.stepNumber,
|
|
@@ -1493,7 +1514,9 @@ async function emitChatUsage(
|
|
|
1493
1514
|
cost: costEstimateFromApplied(input.applied),
|
|
1494
1515
|
attributes: resolveDynamicAttributes(
|
|
1495
1516
|
telemetry,
|
|
1496
|
-
buildTelemetryAttributeContext(telemetry, "chat", {
|
|
1517
|
+
buildTelemetryAttributeContext(telemetry, input.operation === GenAIOperation.Judgment ? "judgment" : "chat", {
|
|
1518
|
+
stepNumber: input.stepNumber,
|
|
1519
|
+
}),
|
|
1497
1520
|
),
|
|
1498
1521
|
headers: input.headers,
|
|
1499
1522
|
};
|
|
@@ -1586,7 +1609,7 @@ export async function recordManualChatTelemetry(
|
|
|
1586
1609
|
});
|
|
1587
1610
|
if (!span) return undefined;
|
|
1588
1611
|
if (options.span && options.attributes) span.setAttributes(options.attributes);
|
|
1589
|
-
if (options.stepNumber != null) span.setAttribute(
|
|
1612
|
+
if (options.stepNumber != null) span.setAttribute(OmpGenAIAttr.AgentStepNumber, options.stepNumber);
|
|
1590
1613
|
span.setAttribute(GenAIAttr.ResponseModel, options.responseModel ?? options.model.name);
|
|
1591
1614
|
if (options.responseId) span.setAttribute(GenAIAttr.ResponseId, options.responseId);
|
|
1592
1615
|
const finishReason = mapStopReason(options.finishReason);
|
|
@@ -1602,6 +1625,7 @@ export async function recordManualChatTelemetry(
|
|
|
1602
1625
|
usage: options.usage,
|
|
1603
1626
|
});
|
|
1604
1627
|
await emitChatUsage(telemetry, span, {
|
|
1628
|
+
operation: GenAIOperation.Chat,
|
|
1605
1629
|
model: options.responseModel ?? options.model.id,
|
|
1606
1630
|
provider: options.model.provider,
|
|
1607
1631
|
serviceTier: options.serviceTier,
|
|
@@ -1619,7 +1643,7 @@ export async function recordManualChatTelemetry(
|
|
|
1619
1643
|
}
|
|
1620
1644
|
if (options.responseText) {
|
|
1621
1645
|
const responseText = stringifyJsonAttribute(summarizeTelemetryTexts([options.responseText]));
|
|
1622
|
-
if (responseText) span.setAttribute(
|
|
1646
|
+
if (responseText) span.setAttribute(OmpGenAIAttr.ResponseText, responseText);
|
|
1623
1647
|
}
|
|
1624
1648
|
if (options.responseToolCalls && options.responseToolCalls.length > 0) {
|
|
1625
1649
|
const calls = options.responseToolCalls.map(call => ({
|
|
@@ -1628,13 +1652,112 @@ export async function recordManualChatTelemetry(
|
|
|
1628
1652
|
input: summarizeTelemetryValue(call.input),
|
|
1629
1653
|
}));
|
|
1630
1654
|
const responseToolCalls = stringifyJsonAttribute(limitTelemetryToolCalls(calls));
|
|
1631
|
-
if (responseToolCalls) span.setAttribute(
|
|
1655
|
+
if (responseToolCalls) span.setAttribute(OmpGenAIAttr.ResponseToolCalls, responseToolCalls);
|
|
1632
1656
|
}
|
|
1633
1657
|
applyTerminalStatus(span, options.finishReason, undefined);
|
|
1634
1658
|
if (options.endSpan ?? options.span === undefined) span.end();
|
|
1635
1659
|
return span;
|
|
1636
1660
|
}
|
|
1637
1661
|
|
|
1662
|
+
/**
|
|
1663
|
+
* One judgment attempt reported to {@link recordJudgmentTelemetry}: a billed
|
|
1664
|
+
* System One request, one prompted-model attempt, or a request answered
|
|
1665
|
+
* entirely from the local judgment cache.
|
|
1666
|
+
*/
|
|
1667
|
+
export interface JudgmentTelemetryOptions {
|
|
1668
|
+
readonly provider: string;
|
|
1669
|
+
/** Requested model id. */
|
|
1670
|
+
readonly model: string;
|
|
1671
|
+
/** Model that answered, when the provider resolves an alias (`jev-latest` → `jev-1.13.0`). */
|
|
1672
|
+
readonly responseModel?: string;
|
|
1673
|
+
/** Caller-level reason the judgment ran; stamped as {@link OmpGenAIAttr.JudgmentPurpose}. */
|
|
1674
|
+
readonly purpose: string;
|
|
1675
|
+
/** Priced usage of this attempt — the same amount the session ledger bills. */
|
|
1676
|
+
readonly usage: Usage;
|
|
1677
|
+
readonly stopReason: StopReason;
|
|
1678
|
+
readonly errorMessage?: string;
|
|
1679
|
+
/** Epoch ms the attempt started. */
|
|
1680
|
+
readonly startTime: number;
|
|
1681
|
+
/** Questions in the request; omitted for prompted backends, which report per completion attempt. */
|
|
1682
|
+
readonly questions?: number;
|
|
1683
|
+
/** Questions answered from the local cache rather than the provider. */
|
|
1684
|
+
readonly cachedQuestions?: number;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* Emit one `judgment` span per judgment attempt and forward any billed usage
|
|
1689
|
+
* to `onCostDelta` / `onChatUsage` with `operation: "judgment"`. Cost is the
|
|
1690
|
+
* attempt's own priced usage rather than {@link AgentTelemetryConfig.costEstimator},
|
|
1691
|
+
* so spans, metrics, and the session ledger agree on every judgment dollar.
|
|
1692
|
+
* Attempts that billed nothing (cache hits, pre-response failures) keep their
|
|
1693
|
+
* span but fire no usage hooks. No-op when telemetry is disabled.
|
|
1694
|
+
*/
|
|
1695
|
+
export async function recordJudgmentTelemetry(
|
|
1696
|
+
telemetry: AgentTelemetry | undefined,
|
|
1697
|
+
options: JudgmentTelemetryOptions,
|
|
1698
|
+
): Promise<void> {
|
|
1699
|
+
if (!telemetry) return;
|
|
1700
|
+
const attributes: Attributes = {
|
|
1701
|
+
[GenAIAttr.RequestModel]: options.model,
|
|
1702
|
+
[OmpGenAIAttr.JudgmentPurpose]: options.purpose,
|
|
1703
|
+
};
|
|
1704
|
+
const provider = normalizeProviderName(telemetry, options.provider);
|
|
1705
|
+
if (provider) attributes[GenAIAttr.ProviderName] = provider;
|
|
1706
|
+
if (options.questions !== undefined) attributes[OmpGenAIAttr.JudgmentQuestions] = options.questions;
|
|
1707
|
+
if (options.cachedQuestions !== undefined) {
|
|
1708
|
+
attributes[OmpGenAIAttr.JudgmentCachedQuestions] = options.cachedQuestions;
|
|
1709
|
+
}
|
|
1710
|
+
const span = startSpan(telemetry, "judgment", `judgment ${options.model}`, {
|
|
1711
|
+
spanKind: SpanKind.CLIENT,
|
|
1712
|
+
attributes,
|
|
1713
|
+
startTime: options.startTime,
|
|
1714
|
+
});
|
|
1715
|
+
if (!span) return;
|
|
1716
|
+
const model = options.responseModel ?? options.model;
|
|
1717
|
+
span.setAttribute(GenAIAttr.ResponseModel, model);
|
|
1718
|
+
const finishReason = mapStopReason(options.stopReason);
|
|
1719
|
+
if (finishReason) span.setAttribute(GenAIAttr.ResponseFinishReasons, [finishReason]);
|
|
1720
|
+
applyUsageAttributes(span, options.usage);
|
|
1721
|
+
const { cost } = options.usage;
|
|
1722
|
+
span.setAttribute(OmpGenAIAttr.CostEstimatedUsd, cost.total);
|
|
1723
|
+
span.setAttribute(OmpGenAIAttr.CostInputUsd, cost.input);
|
|
1724
|
+
span.setAttribute(OmpGenAIAttr.CostOutputUsd, cost.output);
|
|
1725
|
+
if (options.usage.totalTokens > 0 || cost.total > 0) {
|
|
1726
|
+
const applied: AppliedCostEstimate = {
|
|
1727
|
+
costUsd: cost.total,
|
|
1728
|
+
inputUsd: cost.input,
|
|
1729
|
+
outputUsd: cost.output,
|
|
1730
|
+
costUnavailableReason: undefined,
|
|
1731
|
+
};
|
|
1732
|
+
emitCostDelta(telemetry, {
|
|
1733
|
+
agent: normalizedTelemetryAgent(telemetry),
|
|
1734
|
+
conversationId: telemetry.conversationId,
|
|
1735
|
+
stepNumber: undefined,
|
|
1736
|
+
provider: provider ?? options.provider,
|
|
1737
|
+
model,
|
|
1738
|
+
serviceTier: undefined,
|
|
1739
|
+
usage: buildUsageSnapshot(options.usage),
|
|
1740
|
+
costUsd: applied.costUsd,
|
|
1741
|
+
inputUsd: applied.inputUsd,
|
|
1742
|
+
outputUsd: applied.outputUsd,
|
|
1743
|
+
costUnavailableReason: undefined,
|
|
1744
|
+
});
|
|
1745
|
+
await emitChatUsage(telemetry, span, {
|
|
1746
|
+
operation: GenAIOperation.Judgment,
|
|
1747
|
+
model,
|
|
1748
|
+
provider: options.provider,
|
|
1749
|
+
serviceTier: undefined,
|
|
1750
|
+
stepNumber: undefined,
|
|
1751
|
+
usage: options.usage,
|
|
1752
|
+
applied,
|
|
1753
|
+
headers: undefined,
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
applyTerminalStatus(span, options.stopReason, options.errorMessage);
|
|
1757
|
+
safeOnSpanEnd(telemetry, { ...buildTelemetryAttributeContext(telemetry, "judgment", {}), span });
|
|
1758
|
+
span.end();
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1638
1761
|
/**
|
|
1639
1762
|
* Options accepted by {@link instrumentedCompleteSimple}. Mirrors the
|
|
1640
1763
|
* `streamAssistantResponse` chat-span lifecycle for oneshot LLM calls
|
|
@@ -1647,7 +1770,7 @@ export interface InstrumentedChatSpanOptions {
|
|
|
1647
1770
|
/** Step index recorded on the span; defaults to `-1` for non-loop calls. */
|
|
1648
1771
|
readonly stepNumber?: number;
|
|
1649
1772
|
/**
|
|
1650
|
-
* Tag stamped onto `
|
|
1773
|
+
* Tag stamped onto `omp.gen_ai.oneshot.kind`. Values used by the agent:
|
|
1651
1774
|
* `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
|
|
1652
1775
|
* `handoff`, `branch_summary`, `image_question`. Free-form to allow callers
|
|
1653
1776
|
* outside this package to add new kinds without bumping the helper.
|
|
@@ -1726,7 +1849,7 @@ export async function instrumentedCompleteSimple<TApi extends Api>(
|
|
|
1726
1849
|
},
|
|
1727
1850
|
});
|
|
1728
1851
|
if (chatSpan) {
|
|
1729
|
-
if (oneshotKind) chatSpan.setAttribute(
|
|
1852
|
+
if (oneshotKind) chatSpan.setAttribute(OmpGenAIAttr.OneshotKind, oneshotKind);
|
|
1730
1853
|
if (span.attributes) chatSpan.setAttributes(span.attributes);
|
|
1731
1854
|
}
|
|
1732
1855
|
|
|
@@ -1881,7 +2004,7 @@ export function finishExecuteToolSpan(
|
|
|
1881
2004
|
}
|
|
1882
2005
|
|
|
1883
2006
|
/** Span attribute carrying the terminal {@link ToolStatus}. */
|
|
1884
|
-
export const EXECUTE_TOOL_STATUS_ATTR =
|
|
2007
|
+
export const EXECUTE_TOOL_STATUS_ATTR = OmpGenAIAttr.ToolStatus;
|
|
1885
2008
|
|
|
1886
2009
|
/**
|
|
1887
2010
|
* Mapping from non-ok {@link ToolStatus} values to the `error.type` attribute
|
|
@@ -1974,66 +2097,66 @@ export function fireOnRunEnd(telemetry: AgentTelemetry, summary: AgentRunSummary
|
|
|
1974
2097
|
}
|
|
1975
2098
|
}
|
|
1976
2099
|
|
|
1977
|
-
/** Aggregate `
|
|
1978
|
-
export const enum
|
|
1979
|
-
ChatsCount = "
|
|
1980
|
-
ChatsTotalLatencyMs = "
|
|
1981
|
-
ChatsStopReasonPrefix = "
|
|
1982
|
-
ToolsCount = "
|
|
1983
|
-
ToolsOkCount = "
|
|
1984
|
-
ToolsErrorCount = "
|
|
1985
|
-
ToolsSkippedCount = "
|
|
1986
|
-
ToolsBlockedCount = "
|
|
1987
|
-
ToolsTimeoutCount = "
|
|
1988
|
-
ToolsAbortedCount = "
|
|
1989
|
-
ToolsTotalLatencyMs = "
|
|
1990
|
-
ToolsInvoked = "
|
|
1991
|
-
ToolsAvailable = "
|
|
1992
|
-
ToolsUnused = "
|
|
1993
|
-
UsageInputTokensTotal = "
|
|
1994
|
-
UsageOutputTokensTotal = "
|
|
1995
|
-
UsageCacheReadInputTokensTotal = "
|
|
1996
|
-
UsageCacheCreationInputTokensTotal = "
|
|
1997
|
-
UsageReasoningOutputTokensTotal = "
|
|
1998
|
-
UsageTotalTokensTotal = "
|
|
1999
|
-
CostEstimatedUsdTotal = "
|
|
2000
|
-
ErrorsCount = "
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
/** Stamp the aggregate `
|
|
2100
|
+
/** Aggregate `omp.gen_ai.agent.*` attributes stamped on the `invoke_agent` span. */
|
|
2101
|
+
export const enum OmpGenAIAggregateAttr {
|
|
2102
|
+
ChatsCount = "omp.gen_ai.agent.chats.count",
|
|
2103
|
+
ChatsTotalLatencyMs = "omp.gen_ai.agent.chats.total_latency_ms",
|
|
2104
|
+
ChatsStopReasonPrefix = "omp.gen_ai.agent.chats.stop_reason.",
|
|
2105
|
+
ToolsCount = "omp.gen_ai.agent.tools.count",
|
|
2106
|
+
ToolsOkCount = "omp.gen_ai.agent.tools.ok.count",
|
|
2107
|
+
ToolsErrorCount = "omp.gen_ai.agent.tools.error.count",
|
|
2108
|
+
ToolsSkippedCount = "omp.gen_ai.agent.tools.skipped.count",
|
|
2109
|
+
ToolsBlockedCount = "omp.gen_ai.agent.tools.blocked.count",
|
|
2110
|
+
ToolsTimeoutCount = "omp.gen_ai.agent.tools.timeout.count",
|
|
2111
|
+
ToolsAbortedCount = "omp.gen_ai.agent.tools.aborted.count",
|
|
2112
|
+
ToolsTotalLatencyMs = "omp.gen_ai.agent.tools.total_latency_ms",
|
|
2113
|
+
ToolsInvoked = "omp.gen_ai.agent.tools.invoked",
|
|
2114
|
+
ToolsAvailable = "omp.gen_ai.agent.tools.available",
|
|
2115
|
+
ToolsUnused = "omp.gen_ai.agent.tools.unused",
|
|
2116
|
+
UsageInputTokensTotal = "omp.gen_ai.agent.usage.input_tokens.total",
|
|
2117
|
+
UsageOutputTokensTotal = "omp.gen_ai.agent.usage.output_tokens.total",
|
|
2118
|
+
UsageCacheReadInputTokensTotal = "omp.gen_ai.agent.usage.cache_read.input_tokens.total",
|
|
2119
|
+
UsageCacheCreationInputTokensTotal = "omp.gen_ai.agent.usage.cache_creation.input_tokens.total",
|
|
2120
|
+
UsageReasoningOutputTokensTotal = "omp.gen_ai.agent.usage.reasoning.output_tokens.total",
|
|
2121
|
+
UsageTotalTokensTotal = "omp.gen_ai.agent.usage.total_tokens.total",
|
|
2122
|
+
CostEstimatedUsdTotal = "omp.gen_ai.agent.cost.estimated_usd.total",
|
|
2123
|
+
ErrorsCount = "omp.gen_ai.agent.errors.count",
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/** Stamp the aggregate `omp.gen_ai.agent.*` attributes on the given span. */
|
|
2004
2127
|
function applyAggregateAttributes(span: Span, summary: AgentRunSummary, coverage: AgentRunCoverage): void {
|
|
2005
|
-
span.setAttribute(
|
|
2006
|
-
span.setAttribute(
|
|
2128
|
+
span.setAttribute(OmpGenAIAggregateAttr.ChatsCount, summary.chats.total);
|
|
2129
|
+
span.setAttribute(OmpGenAIAggregateAttr.ChatsTotalLatencyMs, summary.chats.totalLatencyMs);
|
|
2007
2130
|
for (const [reason, count] of Object.entries(summary.chats.byStopReason)) {
|
|
2008
|
-
span.setAttribute(`${
|
|
2009
|
-
}
|
|
2010
|
-
span.setAttribute(
|
|
2011
|
-
span.setAttribute(
|
|
2012
|
-
span.setAttribute(
|
|
2013
|
-
span.setAttribute(
|
|
2014
|
-
span.setAttribute(
|
|
2015
|
-
span.setAttribute(
|
|
2016
|
-
span.setAttribute(
|
|
2017
|
-
span.setAttribute(
|
|
2131
|
+
span.setAttribute(`${OmpGenAIAggregateAttr.ChatsStopReasonPrefix}${reason}.count`, count);
|
|
2132
|
+
}
|
|
2133
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsCount, summary.tools.total);
|
|
2134
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsOkCount, summary.tools.ok);
|
|
2135
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsErrorCount, summary.tools.error);
|
|
2136
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsSkippedCount, summary.tools.skipped);
|
|
2137
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsBlockedCount, summary.tools.blocked);
|
|
2138
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsTimeoutCount, summary.tools.timeout);
|
|
2139
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsAbortedCount, summary.tools.aborted);
|
|
2140
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsTotalLatencyMs, summary.tools.totalLatencyMs);
|
|
2018
2141
|
if (coverage.toolsInvoked.length > 0) {
|
|
2019
|
-
span.setAttribute(
|
|
2142
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsInvoked, [...coverage.toolsInvoked]);
|
|
2020
2143
|
}
|
|
2021
2144
|
if (coverage.toolsAvailable.length > 0) {
|
|
2022
|
-
span.setAttribute(
|
|
2145
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsAvailable, [...coverage.toolsAvailable]);
|
|
2023
2146
|
}
|
|
2024
2147
|
if (coverage.toolsUnused.length > 0) {
|
|
2025
|
-
span.setAttribute(
|
|
2026
|
-
}
|
|
2027
|
-
span.setAttribute(
|
|
2028
|
-
span.setAttribute(
|
|
2029
|
-
span.setAttribute(
|
|
2030
|
-
span.setAttribute(
|
|
2031
|
-
span.setAttribute(
|
|
2032
|
-
span.setAttribute(
|
|
2148
|
+
span.setAttribute(OmpGenAIAggregateAttr.ToolsUnused, [...coverage.toolsUnused]);
|
|
2149
|
+
}
|
|
2150
|
+
span.setAttribute(OmpGenAIAggregateAttr.UsageInputTokensTotal, summary.usage.inputTokens);
|
|
2151
|
+
span.setAttribute(OmpGenAIAggregateAttr.UsageOutputTokensTotal, summary.usage.outputTokens);
|
|
2152
|
+
span.setAttribute(OmpGenAIAggregateAttr.UsageCacheReadInputTokensTotal, summary.usage.cachedInputTokens);
|
|
2153
|
+
span.setAttribute(OmpGenAIAggregateAttr.UsageCacheCreationInputTokensTotal, summary.usage.cacheWriteTokens);
|
|
2154
|
+
span.setAttribute(OmpGenAIAggregateAttr.UsageReasoningOutputTokensTotal, summary.usage.reasoningOutputTokens);
|
|
2155
|
+
span.setAttribute(OmpGenAIAggregateAttr.UsageTotalTokensTotal, summary.usage.totalTokens);
|
|
2033
2156
|
if (summary.cost.estimatedUsd > 0) {
|
|
2034
|
-
span.setAttribute(
|
|
2157
|
+
span.setAttribute(OmpGenAIAggregateAttr.CostEstimatedUsdTotal, summary.cost.estimatedUsd);
|
|
2035
2158
|
}
|
|
2036
|
-
span.setAttribute(
|
|
2159
|
+
span.setAttribute(OmpGenAIAggregateAttr.ErrorsCount, summary.errors.total);
|
|
2037
2160
|
}
|
|
2038
2161
|
|
|
2039
2162
|
/**
|
|
@@ -2068,10 +2191,10 @@ export function recordHandoff(
|
|
|
2068
2191
|
const attrs: Attributes = {};
|
|
2069
2192
|
const fromAgent = options.fromAgent ? normalizeAgentIdentity(telemetry, options.fromAgent) : undefined;
|
|
2070
2193
|
const toAgent = normalizeAgentIdentity(telemetry, options.toAgent);
|
|
2071
|
-
if (fromAgent?.name) attrs[
|
|
2072
|
-
if (fromAgent?.id) attrs[
|
|
2073
|
-
if (toAgent.name) attrs[
|
|
2074
|
-
if (toAgent.id) attrs[
|
|
2194
|
+
if (fromAgent?.name) attrs[OmpGenAIAttr.HandoffFromAgentName] = fromAgent.name;
|
|
2195
|
+
if (fromAgent?.id) attrs[OmpGenAIAttr.HandoffFromAgentId] = fromAgent.id;
|
|
2196
|
+
if (toAgent.name) attrs[OmpGenAIAttr.HandoffToAgentName] = toAgent.name;
|
|
2197
|
+
if (toAgent.id) attrs[OmpGenAIAttr.HandoffToAgentId] = toAgent.id;
|
|
2075
2198
|
const name = toAgent.name
|
|
2076
2199
|
? fromAgent?.name
|
|
2077
2200
|
? `handoff ${fromAgent.name} → ${toAgent.name}`
|