diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 6eb8ea4..f90ed5a 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -17,10 +17,15 @@ Use scopes when you want explicit spans for agent, tool, inference, or output wo ```typescript import { + ExecuteToolCallArguments, + ExecuteToolCallResult, ExecuteToolScope, InferenceOperationType, InferenceScope, InvokeAgentScope, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, } from "@microsoft/opentelemetry"; const invokeScope = InvokeAgentScope.start( @@ -36,9 +41,36 @@ const invokeScope = InvokeAgentScope.start( ); invokeScope.run(async () => { + const toolArguments = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + resources: [ + { + id: "drive-item-1", + uri: "https://contoso.example/items/1", + name: "Quarterly plan", + type: "document", + provider: "sharepoint", + identifiers: [{ type: "driveItem", value: "1" }], + container: { + id: "folder-1", + uri: "https://contoso.example/folders/1", + type: "folder", + }, + extension_data: { custom_resource_field: "kept" }, + }, + ], + parameters: { query: "hello", includeArchived: false }, + extension_data: { custom_argument_field: "kept" }, + }); + const toolScope = ExecuteToolScope.start( { conversationId: "conv-123", sessionId: "session-456" }, - { toolName: "Search", input: { query: "hello" } }, + { + toolName: "Search", + arguments: toolArguments, + toolCallId: "tool-call-123", + toolType: "function", + }, { agentId: "agent-1", tenantId: "tenant-1" }, ); @@ -48,6 +80,36 @@ invokeScope.run(async () => { { agentId: "agent-1", tenantId: "tenant-1" }, ); + toolScope.recordResponse( + new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + code: "200", + message: "Completed", + }, + resources: [ + { + id: "drive-item-1", + name: "Quarterly plan", + type: "document", + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + code: "200", + }, + policy: { + decision: ToolPolicyDecision.ALLOW, + id: "policy-1", + name: "AllowDocumentRead", + }, + data: { snippetCount: 3 }, + extension_data: { custom_result_field: "kept" }, + }, + ], + pagination: { has_more: false, total_count: 1 }, + extension_data: { custom_result_field: "kept" }, + }), + ); + toolScope.dispose(); inferenceScope.dispose(); }); @@ -62,6 +124,16 @@ invokeScope.recordResponseParameters({ invokeScope.dispose(); ``` +`ExecuteToolScope` serializes arguments to `gen_ai.tool.call.arguments` and results to +`gen_ai.tool.call.result` as JSON span attributes, so they may contain sensitive data. +Use `extension_data` for provider-specific fields on any typed ExecuteTool model. Non-empty +extension data is emitted under the model's `metadata` JSON property. Metadata keys remain +isolated from declared schema fields, so an `extension_data.action` or +`extension_data.schema_version` value cannot replace the typed `action` or `schema_version`. +Typed payloads that contain invalid enum tokens, non-finite numbers, unsupported values, or +reference cycles are replaced with +`{"serialization_error":"Failed to serialize execute tool payload."}`. + `InvokeAgentScope`, `InferenceScope`, and `ExecuteToolScope` accept `request.sessionId`. When you provide it, those scopes write `microsoft.session.id` directly on the created span instead of relying on later baggage enrichment. `OutputScope` does not currently @@ -90,9 +162,9 @@ captures response and usage values after the agent completes. | `responseParameters.cacheWriteInputTokens` | `gen_ai.usage.cache_write.input_tokens` | | `responseParameters.cacheReadInputTokens` | `gen_ai.usage.cache_read.input_tokens` | | `agentDetails.providerName` | `gen_ai.provider.name` | - System instructions may contain sensitive content. Only capture them when you intend to store prompt text and have reviewed downstream access controls. +intend to store prompt text and have reviewed downstream access controls. ## Baggage And Context diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e45e5..1229eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Default `InvokeAgentScope` spans to `SpanKind.INTERNAL` while preserving explicit span-kind overrides. [#241](https://github.com/microsoft/opentelemetry-distro-javascript/pull/241) ### Features Added +- Add typed ExecuteTool argument and result schemas with default schema_version: "1.0", collision-safe `extension_data` emitted under `metadata`, and non-throwing validation aligned with the .NET and Python distros. [#240](https://github.com/microsoft/opentelemetry-distro-javascript/pull/240) - Add manual `sessionId` propagation to `ExecuteToolScope` and `InferenceScope`, plus opt-in custom baggage enrichment for recognized GenAI spans through `BaggageBuilder.customAttribute()` and `customAttributes()`. [#242](https://github.com/microsoft/opentelemetry-distro-javascript/pull/242) - Add GenAI v1.42 InvokeAgent request, response, cache-token, and provider attribute capture for manual A365 scopes. [#239](https://github.com/microsoft/opentelemetry-distro-javascript/pull/239) diff --git a/src/a365/contracts.ts b/src/a365/contracts.ts index 25166c6..5867ee4 100644 --- a/src/a365/contracts.ts +++ b/src/a365/contracts.ts @@ -9,6 +9,27 @@ */ import type { SpanKind, TimeInput, Link, Context, TraceState } from "@opentelemetry/api"; +import type { ExecuteToolCallArguments } from "./tool-call-models.js"; + +export { + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, + ExecuteToolCallArguments, + ExecuteToolCallResult, +} from "./tool-call-models.js"; +export type { + ToolCallExtensionData, + ToolCallIdentifier, + ToolCallContainer, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultSensitivity, + ToolCallResultPolicy, + ToolCallResultSecurity, + ToolCallResultPagination, + ToolCallResultResource, +} from "./tool-call-models.js"; // --------------------------------------------------------------------------- // Default finish reason (per OTel spec) @@ -422,8 +443,8 @@ export interface InvokeAgentScopeDetails { export interface ToolCallDetails { /** Name of the tool being called (required). */ toolName: string; - /** Arguments passed to the tool, as an object or serialized string. */ - arguments?: Record | string; + /** Arguments passed to the tool, as an object, execute-tool schema model, or serialized string. */ + arguments?: Record | ExecuteToolCallArguments | string; /** Unique identifier of the tool call. */ toolCallId?: string; /** Human-readable description of the tool. */ diff --git a/src/a365/index.ts b/src/a365/index.ts index a34b255..8ea2ac8 100644 --- a/src/a365/index.ts +++ b/src/a365/index.ts @@ -38,6 +38,11 @@ export { InvocationRole, InferenceOperationType, DEFAULT_FINISH_REASON, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, + ExecuteToolCallArguments, + ExecuteToolCallResult, GuardrailDecisionType, GuardrailRiskSeverity, GuardrailTargetType, @@ -56,6 +61,16 @@ export type { ToolCallRequestPart, ToolCallResponsePart, ReasoningPart, + ToolCallExtensionData, + ToolCallIdentifier, + ToolCallContainer, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultSensitivity, + ToolCallResultPolicy, + ToolCallResultSecurity, + ToolCallResultPagination, + ToolCallResultResource, AgentDetails, UserDetails, CallerDetails, diff --git a/src/a365/message-utils.ts b/src/a365/message-utils.ts index b3fd6cf..9dae878 100644 --- a/src/a365/message-utils.ts +++ b/src/a365/message-utils.ts @@ -15,8 +15,45 @@ import type { InputMessagesParam, OutputMessagesParam, SystemInstructionPart, + ToolCallContainer, + ToolCallIdentifier, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultPagination, + ToolCallResultPolicy, + ToolCallResultResource, + ToolCallResultSecurity, + ToolCallResultSensitivity, } from "./contracts.js"; -import { MessageRole, DEFAULT_FINISH_REASON } from "./contracts.js"; +import { + DEFAULT_FINISH_REASON, + ExecuteToolCallArguments, + ExecuteToolCallResult, + MessageRole, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, +} from "./contracts.js"; +import { EXECUTE_TOOL_PAYLOAD_KIND } from "./tool-call-models.js"; + +const EXECUTE_TOOL_SERIALIZATION_ERROR = + '{"serialization_error":"Failed to serialize execute tool payload."}'; + +function isTypedExecuteToolPayload( + value: object, +): value is ExecuteToolCallArguments | ExecuteToolCallResult { + const kind = (value as Partial>)[ + EXECUTE_TOOL_PAYLOAD_KIND + ]; + return kind === "arguments" || kind === "result"; +} + +type JsonValue = null | boolean | number | string | JsonValue[] | JsonRecord; +type JsonRecord = { [key: string]: JsonValue }; + +const TOOL_CALL_ACTION_VALUES = new Set(Object.values(ToolCallAction)); +const TOOL_CALL_OUTCOME_STATUS_VALUES = new Set(Object.values(ToolCallOutcomeStatus)); +const TOOL_POLICY_DECISION_VALUES = new Set(Object.values(ToolPolicyDecision)); /** * Type guard that returns `true` when the input is a structured wrapper @@ -106,6 +143,379 @@ export function serializeMessages(wrapper: InputMessages | OutputMessages): stri } } +/** + * Serializes execute-tool payload objects while keeping telemetry recording non-throwing. + * Returns `undefined` for nullish payloads so callers can omit the attribute. + */ +export function serializeToolPayload(value: object | null | undefined): string | undefined { + if (value == null) { + return undefined; + } + + if (!isTypedExecuteToolPayload(value)) { + return safeSerializeToJson(value as Record, "payload"); + } + + try { + return serializeTypedToolPayload(value); + } catch { + return EXECUTE_TOOL_SERIALIZATION_ERROR; + } +} + +function serializeTypedToolPayload( + value: ExecuteToolCallArguments | ExecuteToolCallResult, +): string { + const stack = new Set(); + const serialized = + value[EXECUTE_TOOL_PAYLOAD_KIND] === "arguments" + ? serializeArguments(value, stack) + : serializeResult(value, stack); + return JSON.stringify(serialized); +} + +function serializeArguments(value: ExecuteToolCallArguments, stack: Set): JsonRecord { + return withActiveContainer(value, stack, () => + withMetadata( + { + schema_version: toOptionalJsonValue(value.schema_version, stack), + resources: serializeOptionalSchemaArray(value.resources, serializeResource, stack), + action: validateOptionalEnum(value.action, TOOL_CALL_ACTION_VALUES, "action"), + parameters: serializeOptionalRecord(value.parameters, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeResult(value: ExecuteToolCallResult, stack: Set): JsonRecord { + return withActiveContainer(value, stack, () => + withMetadata( + { + schema_version: toOptionalJsonValue(value.schema_version, stack), + outcome: serializeOptionalSchemaObject(value.outcome, serializeOutcome, stack), + resources: serializeOptionalSchemaArray(value.resources, serializeResultResource, stack), + data: serializeOptionalRecord(value.data, stack), + pagination: serializeOptionalSchemaObject(value.pagination, serializePagination, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeIdentifier(value: ToolCallIdentifier, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + type: toOptionalJsonValue(value.type, stack), + value: toOptionalJsonValue(value.value, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeContainer(value: ToolCallContainer, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + id: toOptionalJsonValue(value.id, stack), + uri: toOptionalJsonValue(value.uri, stack), + type: toOptionalJsonValue(value.type, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeResource(value: ToolCallResource, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + ...serializeResourceFields(value, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeResultResource(value: ToolCallResultResource, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + ...serializeResourceFields(value, stack), + outcome: serializeOptionalSchemaObject(value.outcome, serializeOutcome, stack), + sensitivity: serializeOptionalSchemaObject(value.sensitivity, serializeSensitivity, stack), + policy: serializeOptionalSchemaObject(value.policy, serializePolicy, stack), + security: serializeOptionalSchemaObject(value.security, serializeSecurity, stack), + data: serializeOptionalRecord(value.data, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeResourceFields( + value: ToolCallResource, + stack: Set, +): Record { + return { + id: toOptionalJsonValue(value.id, stack), + uri: toOptionalJsonValue(value.uri, stack), + name: toOptionalJsonValue(value.name, stack), + type: toOptionalJsonValue(value.type, stack), + provider: toOptionalJsonValue(value.provider, stack), + identifiers: serializeOptionalSchemaArray(value.identifiers, serializeIdentifier, stack), + container: serializeOptionalSchemaObject(value.container, serializeContainer, stack), + }; +} + +function serializeOutcome(value: ToolCallResultOutcome, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + status: validateOptionalEnum(value.status, TOOL_CALL_OUTCOME_STATUS_VALUES, "status"), + code: toOptionalJsonValue(value.code, stack), + provider_code: toOptionalJsonValue(value.provider_code, stack), + message: toOptionalJsonValue(value.message, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeSensitivity(value: ToolCallResultSensitivity, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { label_id: toOptionalJsonValue(value.label_id, stack) }, + value.extension_data, + stack, + ), + ); +} + +function serializePolicy(value: ToolCallResultPolicy, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + decision: validateOptionalEnum(value.decision, TOOL_POLICY_DECISION_VALUES, "decision"), + id: toOptionalJsonValue(value.id, stack), + name: toOptionalJsonValue(value.name, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeSecurity(value: ToolCallResultSecurity, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { xpia_detected: toOptionalJsonValue(value.xpia_detected, stack) }, + value.extension_data, + stack, + ), + ); +} + +function serializePagination(value: ToolCallResultPagination, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + has_more: toOptionalJsonValue(value.has_more, stack), + next_cursor: toOptionalJsonValue(value.next_cursor, stack), + total_count: toOptionalJsonValue(value.total_count, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeSchemaObject( + value: T, + stack: Set, + serialize: () => JsonRecord, +): JsonRecord { + if (!isPlainRecord(value)) { + throw new TypeError("Execute tool schema values must be plain objects."); + } + return withActiveContainer(value, stack, serialize); +} + +function serializeOptionalSchemaObject( + value: T | null | undefined, + serialize: (item: T, stack: Set) => JsonRecord, + stack: Set, +): JsonRecord | undefined { + return value == null ? undefined : serialize(value, stack); +} + +function serializeOptionalSchemaArray( + value: T[] | null | undefined, + serialize: (item: T, stack: Set) => JsonRecord, + stack: Set, +): JsonValue[] | undefined { + if (value == null) { + return undefined; + } + if (!Array.isArray(value)) { + throw new TypeError("Execute tool schema collections must be arrays."); + } + return serializeArray(value, stack, (item) => serialize(item, stack)); +} + +function serializeOptionalRecord( + value: Record | null | undefined, + stack: Set, +): JsonRecord | undefined { + return value == null ? undefined : toJsonRecord(value, stack); +} + +function withMetadata( + declared: Record, + extensionData: Record | null | undefined, + stack: Set, +): JsonRecord { + const serialized: JsonRecord = {}; + for (const [key, value] of Object.entries(declared)) { + if (value !== undefined) { + serialized[key] = value; + } + } + if (extensionData != null) { + if (!isPlainRecord(extensionData)) { + throw new TypeError("Execute tool extension data must be a plain object."); + } + const metadata = toJsonRecord(extensionData, stack); + if (Object.keys(metadata).length > 0) { + serialized.metadata = metadata; + } + } + return serialized; +} + +function validateOptionalEnum( + value: unknown, + allowed: ReadonlySet, + field: string, +): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value !== "string" || !allowed.has(value)) { + throw new TypeError(`Invalid execute tool ${field}.`); + } + return value; +} + +function toOptionalJsonValue(value: unknown, stack: Set): JsonValue | undefined { + return value == null ? undefined : toJsonValue(value, stack); +} + +function toJsonValue(value: unknown, stack: Set): JsonValue { + if (value === null) { + return null; + } + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("Execute tool payload numbers must be finite."); + } + return value; + } + if ( + value === undefined || + typeof value === "bigint" || + typeof value === "function" || + typeof value === "symbol" + ) { + throw new TypeError(`Unsupported execute tool payload value: ${typeof value}.`); + } + if (value instanceof Date) { + if (!Number.isFinite(value.getTime())) { + throw new TypeError("Execute tool payload dates must be valid."); + } + return value.toISOString(); + } + if (value instanceof Uint8Array) { + return Buffer.from(value).toString("base64"); + } + if (Array.isArray(value)) { + return serializeArray(value, stack, (item) => toJsonValue(item, stack)); + } + if (value instanceof Set) { + return withActiveContainer(value, stack, () => + Array.from(value, (item) => toJsonValue(item, stack)), + ); + } + if (isPlainRecord(value)) { + return toJsonRecord(value, stack); + } + throw new TypeError( + `Unsupported execute tool payload object: ${value.constructor?.name ?? "unknown"}.`, + ); +} + +function toJsonRecord(value: Record, stack: Set): JsonRecord { + if (!isPlainRecord(value)) { + throw new TypeError("Execute tool payload mappings must be plain objects."); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError("Execute tool payload mappings must use string keys."); + } + return withActiveContainer(value, stack, () => { + const serialized = Object.create(null) as JsonRecord; + for (const [key, item] of Object.entries(value)) { + serialized[key] = toJsonValue(item, stack); + } + return serialized; + }); +} + +function serializeArray( + value: T[], + stack: Set, + serialize: (item: T) => JsonValue, +): JsonValue[] { + return withActiveContainer(value, stack, () => { + const serialized: JsonValue[] = []; + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) { + throw new TypeError("Execute tool payload arrays must not be sparse."); + } + serialized.push(serialize(value[index])); + } + return serialized; + }); +} + +function withActiveContainer(value: object, stack: Set, serialize: () => T): T { + if (stack.has(value)) { + throw new TypeError("Circular reference detected in execute tool payload."); + } + stack.add(value); + try { + return serialize(); + } finally { + stack.delete(value); + } +} + +function isPlainRecord(value: object): value is Record { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + /** * Serializes system instruction parts to a JSON array. * diff --git a/src/a365/scopes/ExecuteToolScope.ts b/src/a365/scopes/ExecuteToolScope.ts index fbd2d07..b33d3c5 100644 --- a/src/a365/scopes/ExecuteToolScope.ts +++ b/src/a365/scopes/ExecuteToolScope.ts @@ -4,10 +4,11 @@ import { SpanKind } from "@opentelemetry/api"; import { OpenTelemetryScope } from "./OpenTelemetryScope.js"; import { OpenTelemetryConstants } from "../constants.js"; -import { safeSerializeToJson } from "../message-utils.js"; +import { safeSerializeToJson, serializeToolPayload } from "../message-utils.js"; import type { ToolCallDetails, AgentDetails, + ExecuteToolCallResult, UserDetails, Request, SpanDetails, @@ -65,7 +66,9 @@ export class ExecuteToolScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, toolName); this.setTagMaybe( OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, - args != null ? safeSerializeToJson(args, "arguments") : undefined, + typeof args === "string" + ? safeSerializeToJson(args, "arguments") + : serializeToolPayload(args), ); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, toolType); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, toolCallId); @@ -88,10 +91,16 @@ export class ExecuteToolScope extends OpenTelemetryScope { * Records response information for telemetry tracking. * Objects are serialized to JSON automatically. */ - public recordResponse(response: Record | string): void { + public recordResponse(response: ExecuteToolCallResult | null | undefined): void; + public recordResponse(response: Record | string): void; + public recordResponse( + response: Record | ExecuteToolCallResult | string | null | undefined, + ): void { this.setTagMaybe( OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, - safeSerializeToJson(response, "result"), + typeof response === "string" + ? safeSerializeToJson(response, "result") + : serializeToolPayload(response), ); } } diff --git a/src/a365/tool-call-models.ts b/src/a365/tool-call-models.ts new file mode 100644 index 0000000..bedc6d5 --- /dev/null +++ b/src/a365/tool-call-models.ts @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const EXECUTE_TOOL_PAYLOAD_KIND = Symbol.for( + "@microsoft/opentelemetry.execute_tool_payload_kind", +); + +/** Action requested by an execute tool call. */ +export enum ToolCallAction { + /** Create a resource. */ + CREATE = "create", + /** Read a resource. */ + READ = "read", + /** Update a resource. */ + UPDATE = "update", + /** Delete a resource. */ + DELETE = "delete", +} + +/** Outcome status reported for an execute tool call result. */ +export enum ToolCallOutcomeStatus { + /** The tool call completed successfully. */ + SUCCESS = "success", + /** The tool call failed. */ + FAILURE = "failure", +} + +/** Policy decision recorded for an execute tool call result. */ +export enum ToolPolicyDecision { + /** The policy allows the tool call. */ + ALLOW = "allow", + /** The policy denies the tool call. */ + DENY = "deny", +} + +/** Provider-specific properties not defined by an execute tool schema model. */ +export interface ToolCallExtensionData { + /** Properties serialized under the `metadata` wire field. */ + extension_data?: Record; +} + +/** Resource identifier details for an execute tool call. */ +export interface ToolCallIdentifier extends ToolCallExtensionData { + /** Identifier type. */ + type?: string; + /** Identifier value. */ + value?: string; +} + +/** Container metadata for a resource reference. */ +export interface ToolCallContainer extends ToolCallExtensionData { + /** Container identifier. */ + id?: string; + /** Container URI. */ + uri?: string; + /** Container type. */ + type?: string; +} + +/** Resource metadata for an execute tool call. */ +export interface ToolCallResource extends ToolCallExtensionData { + /** Resource identifier. */ + id?: string; + /** Resource URI. */ + uri?: string; + /** Resource name. */ + name?: string; + /** Resource type. */ + type?: string; + /** Resource provider. */ + provider?: string; + /** Provider-specific identifiers for the resource. */ + identifiers?: ToolCallIdentifier[]; + /** Container that owns the resource. */ + container?: ToolCallContainer; +} + +/** Outcome details for an execute tool call result. */ +export interface ToolCallResultOutcome extends ToolCallExtensionData { + /** Whether the tool call succeeded or failed. */ + status?: ToolCallOutcomeStatus; + /** Tool-specific result code. */ + code?: string; + /** Provider-specific result code. */ + provider_code?: string; + /** Human-readable outcome message. */ + message?: string; +} + +/** Sensitivity metadata for a tool call result. */ +export interface ToolCallResultSensitivity extends ToolCallExtensionData { + /** Sensitivity label identifier. */ + label_id?: string; +} + +/** Policy metadata for a tool call result. */ +export interface ToolCallResultPolicy extends ToolCallExtensionData { + /** Policy decision for the tool call. */ + decision?: ToolPolicyDecision; + /** Policy identifier. */ + id?: string; + /** Policy name. */ + name?: string; +} + +/** Security metadata for a tool call result. */ +export interface ToolCallResultSecurity extends ToolCallExtensionData { + /** Whether XPIA was detected. */ + xpia_detected?: boolean; +} + +/** Pagination metadata for a tool call result. */ +export interface ToolCallResultPagination extends ToolCallExtensionData { + /** Whether more results are available. */ + has_more?: boolean; + /** Cursor for the next page of results. */ + next_cursor?: string; + /** Total result count when known. */ + total_count?: number; +} + +/** Resource payload returned by an execute tool call. */ +export interface ToolCallResultResource extends ToolCallResource { + /** Outcome for this resource. */ + outcome?: ToolCallResultOutcome; + /** Sensitivity metadata for this resource. */ + sensitivity?: ToolCallResultSensitivity; + /** Policy metadata for this resource. */ + policy?: ToolCallResultPolicy; + /** Security metadata for this resource. */ + security?: ToolCallResultSecurity; + /** Resource-specific result data. */ + data?: Record; +} + +/** Structured arguments for an execute tool call. */ +export class ExecuteToolCallArguments implements ToolCallExtensionData { + declare readonly [EXECUTE_TOOL_PAYLOAD_KIND]: "arguments"; + /** Schema version for this payload. */ + declare schema_version: string; + /** Resources referenced by the tool call. */ + declare resources?: ToolCallResource[]; + /** Requested action for the tool call. */ + declare action?: ToolCallAction; + /** Tool parameters for the call. */ + declare parameters?: Record; + /** Provider-specific properties serialized under `metadata`. */ + declare extension_data?: Record; + + constructor(init: Partial = {}) { + Object.defineProperty(this, EXECUTE_TOOL_PAYLOAD_KIND, { value: "arguments" }); + this.schema_version = init.schema_version === undefined ? "1.0" : init.schema_version; + if (init.resources !== undefined) this.resources = init.resources; + if (init.action !== undefined) this.action = init.action; + if (init.parameters !== undefined) this.parameters = init.parameters; + if (init.extension_data !== undefined) this.extension_data = init.extension_data; + } +} + +/** Structured result for an execute tool call. */ +export class ExecuteToolCallResult implements ToolCallExtensionData { + declare readonly [EXECUTE_TOOL_PAYLOAD_KIND]: "result"; + /** Schema version for this payload. */ + declare schema_version: string; + /** Overall tool call outcome. */ + declare outcome?: ToolCallResultOutcome; + /** Resources returned by the tool call. */ + declare resources?: ToolCallResultResource[]; + /** Tool result data. */ + declare data?: Record; + /** Pagination metadata for the result set. */ + declare pagination?: ToolCallResultPagination; + /** Provider-specific properties serialized under `metadata`. */ + declare extension_data?: Record; + + constructor(init: Partial = {}) { + Object.defineProperty(this, EXECUTE_TOOL_PAYLOAD_KIND, { value: "result" }); + this.schema_version = init.schema_version === undefined ? "1.0" : init.schema_version; + if (init.outcome !== undefined) this.outcome = init.outcome; + if (init.resources !== undefined) this.resources = init.resources; + if (init.data !== undefined) this.data = init.data; + if (init.pagination !== undefined) this.pagination = init.pagination; + if (init.extension_data !== undefined) this.extension_data = init.extension_data; + } +} diff --git a/src/index.ts b/src/index.ts index 148f09a..e5f4564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,11 @@ export { InvocationRole, InferenceOperationType, DEFAULT_FINISH_REASON, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, + ExecuteToolCallArguments, + ExecuteToolCallResult, GuardrailDecisionType, GuardrailRiskSeverity, GuardrailTargetType, @@ -93,6 +98,16 @@ export type { ToolCallRequestPart, ToolCallResponsePart, ReasoningPart, + ToolCallExtensionData, + ToolCallIdentifier, + ToolCallContainer, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultSensitivity, + ToolCallResultPolicy, + ToolCallResultSecurity, + ToolCallResultPagination, + ToolCallResultResource, HeadersCarrier, GuardrailDetails, GuardrailFinding, diff --git a/test/internal/functional/esmBuildImport.test.ts b/test/internal/functional/esmBuildImport.test.ts index 489902e..7016371 100644 --- a/test/internal/functional/esmBuildImport.test.ts +++ b/test/internal/functional/esmBuildImport.test.ts @@ -2,7 +2,9 @@ // Licensed under the MIT License. import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; describe("ESM build import regression", () => { @@ -16,4 +18,52 @@ describe("ESM build import regression", () => { await expect(import(modulePath)).resolves.toBeDefined(); }); + + it("recognizes typed execute tool models across ESM and CommonJS builds", async () => { + const esmModelsPath = resolve(process.cwd(), "dist/esm/a365/tool-call-models.js"); + const esmMessageUtilsPath = resolve(process.cwd(), "dist/esm/a365/message-utils.js"); + const cjsModelsPath = resolve(process.cwd(), "dist/commonjs/a365/tool-call-models.js"); + const cjsMessageUtilsPath = resolve(process.cwd(), "dist/commonjs/a365/message-utils.js"); + + if ( + !existsSync(esmModelsPath) || + !existsSync(esmMessageUtilsPath) || + !existsSync(cjsModelsPath) || + !existsSync(cjsMessageUtilsPath) + ) { + return; + } + + const require = createRequire(import.meta.url); + const esmModels = await import(pathToFileURL(esmModelsPath).href); + const esmMessageUtils = await import(pathToFileURL(esmMessageUtilsPath).href); + const cjsModels = require(cjsModelsPath); + const cjsMessageUtils = require(cjsMessageUtilsPath); + const expected = { + schema_version: "1.0", + action: "read", + metadata: { action: "write" }, + }; + + expect( + JSON.parse( + esmMessageUtils.serializeToolPayload( + new cjsModels.ExecuteToolCallArguments({ + action: cjsModels.ToolCallAction.READ, + extension_data: { action: "write" }, + }), + ), + ), + ).toEqual(expected); + expect( + JSON.parse( + cjsMessageUtils.serializeToolPayload( + new esmModels.ExecuteToolCallArguments({ + action: esmModels.ToolCallAction.READ, + extension_data: { action: "write" }, + }), + ), + ), + ).toEqual(expected); + }); }); diff --git a/test/internal/unit/a365/executeToolJsonModels.test.ts b/test/internal/unit/a365/executeToolJsonModels.test.ts new file mode 100644 index 0000000..57d25af --- /dev/null +++ b/test/internal/unit/a365/executeToolJsonModels.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, expectTypeOf, it } from "vitest"; + +import * as a365 from "../../../../src/a365/index.js"; +import * as rootExports from "../../../../src/index.js"; +import type { ToolCallDetails, ToolCallExtensionData } from "../../../../src/a365/index.js"; +import type { ToolCallExtensionData as RootToolCallExtensionData } from "../../../../src/index.js"; + +describe("execute tool JSON models", () => { + it("exports execute tool model values from the A365 and root barrels", () => { + expect(a365.ExecuteToolCallArguments).toBeDefined(); + expect(a365.ExecuteToolCallResult).toBeDefined(); + expect(a365.ToolCallAction).toBeDefined(); + expect(a365.ToolCallOutcomeStatus).toBeDefined(); + expect(a365.ToolPolicyDecision).toBeDefined(); + + expect(rootExports.ExecuteToolCallArguments).toBe(a365.ExecuteToolCallArguments); + expect(rootExports.ExecuteToolCallResult).toBe(a365.ExecuteToolCallResult); + expect(rootExports.ToolCallAction).toBe(a365.ToolCallAction); + expect(rootExports.ToolCallOutcomeStatus).toBe(a365.ToolCallOutcomeStatus); + expect(rootExports.ToolPolicyDecision).toBe(a365.ToolPolicyDecision); + expectTypeOf().toEqualTypeOf(); + }); + + it("defaults schema_version when execute tool call arguments are constructed with no input", () => { + const argumentsModel = new a365.ExecuteToolCallArguments(); + + expect(argumentsModel).toEqual({ schema_version: "1.0" }); + }); + + it("defaults schema_version when execute tool call results are constructed with no input", () => { + const resultModel = new a365.ExecuteToolCallResult(); + + expect(resultModel).toEqual({ schema_version: "1.0" }); + }); + + it("defaults schema_version on execute tool call arguments and preserves explicit values", () => { + const defaultArgs = new a365.ExecuteToolCallArguments({ + action: a365.ToolCallAction.READ, + resources: [ + { + id: "drive-item-1", + uri: "https://contoso.example/items/1", + name: "Quarterly plan", + type: "document", + provider: "sharepoint", + identifiers: [ + { + type: "driveItem", + value: "1", + extension_data: { provider_code: "sp" }, + }, + ], + container: { + id: "folder-1", + uri: "https://contoso.example/folders/1", + type: "folder", + extension_data: { label_id: "container-label" }, + }, + extension_data: { custom_resource_field: true }, + }, + ], + parameters: { query: "plan" }, + extension_data: { top_level_extra: "kept" }, + }); + + expect(defaultArgs).toMatchObject({ + schema_version: "1.0", + action: "read", + resources: [ + { + identifiers: [ + { + type: "driveItem", + value: "1", + extension_data: { provider_code: "sp" }, + }, + ], + container: { extension_data: { label_id: "container-label" } }, + extension_data: { custom_resource_field: true }, + }, + ], + extension_data: { top_level_extra: "kept" }, + }); + + const explicitArgs = new a365.ExecuteToolCallArguments({ schema_version: "2.0" }); + expect(explicitArgs.schema_version).toBe("2.0"); + }); + + it("preserves explicit null schema versions for omission during serialization", () => { + const argumentsModel = new a365.ExecuteToolCallArguments({ schema_version: null as any }); + const resultModel = new a365.ExecuteToolCallResult({ schema_version: null as any }); + + expect(argumentsModel.schema_version).toBeNull(); + expect(resultModel.schema_version).toBeNull(); + }); + + it("allows execute tool call argument models in ToolCallDetails.arguments", () => { + const argumentsModel = new a365.ExecuteToolCallArguments({ + action: a365.ToolCallAction.READ, + parameters: { query: "plan" }, + }); + const existingObjectArguments: ToolCallDetails = { + toolName: "search", + arguments: { query: "plan" }, + }; + const existingStringArguments: ToolCallDetails = { + toolName: "search", + arguments: '{"query":"plan"}', + }; + const details: ToolCallDetails = { + toolName: "search", + arguments: argumentsModel, + }; + + expect(existingObjectArguments.arguments).toEqual({ query: "plan" }); + expect(existingStringArguments.arguments).toBe('{"query":"plan"}'); + expect(details.arguments).toBe(argumentsModel); + expectTypeOf(details.arguments).toMatchTypeOf< + Record | a365.ExecuteToolCallArguments | string | undefined + >(); + }); + + it("defaults schema_version on execute tool call results and preserves exact wire fields", () => { + const defaultResult = new a365.ExecuteToolCallResult({ + outcome: { + status: a365.ToolCallOutcomeStatus.SUCCESS, + code: "200", + provider_code: "graph-ok", + message: "Completed", + }, + resources: [ + { + id: "doc-1", + uri: "https://contoso.example/items/1", + name: "Quarterly plan", + type: "document", + provider: "sharepoint", + identifiers: [ + { + type: "driveItem", + value: "1", + extension_data: { provider_code: "sp" }, + }, + ], + container: { + id: "folder-1", + uri: "https://contoso.example/folders/1", + type: "folder", + }, + outcome: { + status: a365.ToolCallOutcomeStatus.FAILURE, + provider_code: "partial-failure", + message: "1 record skipped", + }, + sensitivity: { + label_id: "secret", + extension_data: { sensitivity_extra: "kept" }, + }, + policy: { + decision: a365.ToolPolicyDecision.ALLOW, + id: "policy-1", + name: "AllowPolicy", + }, + security: { xpia_detected: true }, + data: { skipped: 1 }, + extension_data: { resource_extra: "kept" }, + }, + ], + data: { documents: 1 }, + pagination: { has_more: true, next_cursor: "cursor-2", total_count: 10 }, + extension_data: { result_extra: "kept" }, + }); + + expect(defaultResult).toMatchObject({ + schema_version: "1.0", + outcome: { + status: "success", + provider_code: "graph-ok", + }, + resources: [ + { + outcome: { status: "failure", provider_code: "partial-failure" }, + sensitivity: { + label_id: "secret", + extension_data: { sensitivity_extra: "kept" }, + }, + policy: { decision: "allow" }, + security: { xpia_detected: true }, + extension_data: { resource_extra: "kept" }, + }, + ], + pagination: { has_more: true, next_cursor: "cursor-2", total_count: 10 }, + extension_data: { result_extra: "kept" }, + }); + + const explicitResult = new a365.ExecuteToolCallResult({ schema_version: "2.1" }); + expect(explicitResult.schema_version).toBe("2.1"); + }); + + it("does not copy undeclared top-level properties into typed models", () => { + const argumentsModel = new a365.ExecuteToolCallArguments({ + action: a365.ToolCallAction.READ, + extension_data: { action: "write", schema_version: "9.9" }, + top_level_extra: "not-extension-data", + } as any); + const resultModel = new a365.ExecuteToolCallResult({ + extension_data: { outcome: "provider-outcome" }, + result_extra: "not-extension-data", + } as any); + + expect(argumentsModel).toEqual({ + schema_version: "1.0", + action: "read", + extension_data: { action: "write", schema_version: "9.9" }, + }); + expect(resultModel).toEqual({ + schema_version: "1.0", + extension_data: { outcome: "provider-outcome" }, + }); + }); +}); diff --git a/test/internal/unit/a365/messageUtils.test.ts b/test/internal/unit/a365/messageUtils.test.ts index 7430cad..34918d4 100644 --- a/test/internal/unit/a365/messageUtils.test.ts +++ b/test/internal/unit/a365/messageUtils.test.ts @@ -3,7 +3,15 @@ import { describe, it, expect } from "vitest"; -import { MessageRole, Modality } from "../../../../src/a365/contracts.js"; +import { + ExecuteToolCallArguments, + ExecuteToolCallResult, + MessageRole, + Modality, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, +} from "../../../../src/a365/contracts.js"; import type { InputMessages, OutputMessages } from "../../../../src/a365/contracts.js"; import { isWrappedMessages, @@ -12,6 +20,7 @@ import { normalizeInputMessages, normalizeOutputMessages, serializeMessages, + serializeToolPayload, } from "../../../../src/a365/message-utils.js"; describe("isWrappedMessages", () => { @@ -318,3 +327,307 @@ describe("serializeMessages", () => { expect(parsed[2].parts[0].type).toBe("custom_annotation"); }); }); + +describe("serializeToolPayload", () => { + const serializationError = '{"serialization_error":"Failed to serialize execute tool payload."}'; + const legacySerializationError = '{"error":"serialization failed"}'; + + it("returns undefined for nullish payloads", () => { + expect(serializeToolPayload(undefined)).toBeUndefined(); + expect(serializeToolPayload(null)).toBeUndefined(); + }); + + it("serializes extension data as metadata without replacing declared fields", () => { + const payload = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + parameters: { + query: "GDPR", + filters: { sensitivity: "high", includeArchived: true }, + }, + resources: [ + { + id: "doc-1", + type: "document", + provider: "sharepoint", + extension_data: { provider_resource_type: "page" }, + }, + ], + extension_data: { + action: "write", + schema_version: "9.9", + request_context: { scenario: "enterprise-search" }, + }, + }); + + const serialized = serializeToolPayload(payload); + const parsed = JSON.parse(serialized as string); + + expect(parsed).toEqual({ + schema_version: "1.0", + action: "read", + parameters: { + query: "GDPR", + filters: { sensitivity: "high", includeArchived: true }, + }, + resources: [ + { + id: "doc-1", + type: "document", + provider: "sharepoint", + metadata: { provider_resource_type: "page" }, + }, + ], + metadata: { + action: "write", + schema_version: "9.9", + request_context: { scenario: "enterprise-search" }, + }, + }); + }); + + it("keeps nested extension keys inside metadata", () => { + const payload = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + code: "ok", + extension_data: { code: "provider-code", status: "provider-status" }, + }, + resources: [ + { + policy: { + decision: ToolPolicyDecision.ALLOW, + extension_data: { decision: "conditional-allow" }, + }, + }, + ], + }); + + expect(JSON.parse(serializeToolPayload(payload) as string)).toEqual({ + schema_version: "1.0", + outcome: { + status: "success", + code: "ok", + metadata: { code: "provider-code", status: "provider-status" }, + }, + resources: [ + { + policy: { + decision: "allow", + metadata: { decision: "conditional-allow" }, + }, + }, + ], + }); + }); + + it("omits nullish declared fields and preserves nulls inside mappings and arrays", () => { + const payload = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + provider_code: null as any, + extension_data: { provider_outcome: null, attempts: 0 }, + }, + data: { content: null, matches: [null, 1] }, + extension_data: { provider_result: null, cached: false }, + }); + + expect(JSON.parse(serializeToolPayload(payload) as string)).toEqual({ + schema_version: "1.0", + outcome: { + status: "success", + metadata: { provider_outcome: null, attempts: 0 }, + }, + data: { content: null, matches: [null, 1] }, + metadata: { provider_result: null, cached: false }, + }); + }); + + it("omits explicit null schema versions", () => { + const payload = new ExecuteToolCallArguments({ schema_version: null as any }); + + expect(JSON.parse(serializeToolPayload(payload) as string)).toEqual({}); + }); + + it("preserves __proto__ as an own mapping key", () => { + const data = Object.create(null) as Record; + Object.defineProperty(data, "__proto__", { + value: { provider: "graph" }, + enumerable: true, + }); + data.kept = 1; + + const serialized = serializeToolPayload( + new ExecuteToolCallResult({ + data, + extension_data: data, + }), + ); + + const parsed = JSON.parse(serialized as string); + + expect(parsed.schema_version).toBe("1.0"); + expect(parsed.data.kept).toBe(1); + expect(parsed.metadata.kept).toBe(1); + expect(Object.hasOwn(parsed.data, "__proto__")).toBe(true); + expect(Object.hasOwn(parsed.metadata, "__proto__")).toBe(true); + expect(parsed.data["__proto__"]).toEqual({ provider: "graph" }); + expect(parsed.metadata["__proto__"]).toEqual({ provider: "graph" }); + }); + + it.each([ + ["action", new ExecuteToolCallArguments({ action: "READ" as any })], + [ + "outcome status", + new ExecuteToolCallResult({ outcome: { status: "ok" as ToolCallOutcomeStatus } }), + ], + [ + "policy decision", + new ExecuteToolCallResult({ + resources: [{ policy: { decision: "permit" as ToolPolicyDecision } }], + }), + ], + ])("returns the exact fallback for an invalid %s", (_name, payload) => { + expect(serializeToolPayload(payload)).toBe(serializationError); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + "returns the exact fallback for non-finite number %s", + (value) => { + expect(serializeToolPayload(new ExecuteToolCallResult({ data: { value } }))).toBe( + serializationError, + ); + }, + ); + + it.each([ + ["undefined", undefined], + ["function", () => "unsupported"], + ["symbol", Symbol("unsupported")], + ["bigint", BigInt(1)], + ])("returns the exact fallback for unsupported %s mapping values", (_name, value) => { + expect(serializeToolPayload(new ExecuteToolCallArguments({ parameters: { value } }))).toBe( + serializationError, + ); + }); + + it("returns the exact fallback for sparse arrays", () => { + const values = new Array(2); + values[1] = 1; + + expect(serializeToolPayload(new ExecuteToolCallResult({ data: { values } }))).toBe( + serializationError, + ); + }); + + it("returns the exact fallback when a sparse array inherits an indexed value", () => { + const values = new Array(1); + Object.setPrototypeOf(values, { 0: "inherited" }); + + expect(serializeToolPayload(new ExecuteToolCallResult({ data: { values } }))).toBe( + serializationError, + ); + }); + + it("returns the exact fallback for symbol-keyed mappings", () => { + const data = { kept: true }; + Object.defineProperty(data, Symbol("unsupported"), { + value: "dropped", + enumerable: true, + }); + + expect(serializeToolPayload(new ExecuteToolCallResult({ data }))).toBe(serializationError); + expect(serializeToolPayload(new ExecuteToolCallResult({ extension_data: data }))).toBe( + serializationError, + ); + }); + + it("returns the exact fallback when extension data is not a mapping", () => { + expect( + serializeToolPayload( + new ExecuteToolCallArguments({ extension_data: [] as unknown as Record }), + ), + ).toBe(serializationError); + }); + + it("serializes supported JavaScript scalar and collection values", () => { + const payload = new ExecuteToolCallResult({ + data: { + timestamp: new Date("2026-01-02T03:04:05.000Z"), + bytes: new Uint8Array([0, 1, 2, 3]), + scopes: new Set(["read", "write"]), + }, + }); + + expect(JSON.parse(serializeToolPayload(payload) as string).data).toEqual({ + timestamp: "2026-01-02T03:04:05.000Z", + bytes: "AAECAw==", + scopes: ["read", "write"], + }); + }); + + it("serializes repeated references that are not cycles", () => { + const shared = { value: true }; + const payload = new ExecuteToolCallResult({ + data: { first: shared, second: shared }, + }); + + expect(JSON.parse(serializeToolPayload(payload) as string).data).toEqual({ + first: { value: true }, + second: { value: true }, + }); + }); + + it("returns the legacy fallback for circular generic payloads", () => { + const payload: Record = { a: 1 }; + payload.self = payload; + + expect(serializeToolPayload(payload)).toBe(legacySerializationError); + }); + + it("returns the exact fallback for circular ExecuteToolCallArguments payloads", () => { + const extension_data: Record = {}; + const payload = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + extension_data, + }); + extension_data.self = payload; + + expect(serializeToolPayload(payload)).toBe(serializationError); + }); + + it("returns the exact fallback for circular ExecuteToolCallResult payloads", () => { + const data: Record = { count: 1 }; + const result = new ExecuteToolCallResult({ + outcome: { status: ToolCallOutcomeStatus.SUCCESS }, + data, + }); + data.self = data; + + expect(serializeToolPayload(result)).toBe(serializationError); + }); + + it("returns the exact fallback for bigint payloads", () => { + expect( + serializeToolPayload( + new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + extension_data: { count: BigInt(1) }, + }), + ), + ).toBe(serializationError); + }); + + it("returns the exact fallback when payload serialization throws", () => { + const extension_data = { + get value(): never { + throw new Error("boom"); + }, + }; + const payload = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + extension_data, + }); + + expect(serializeToolPayload(payload)).toBe(serializationError); + }); +}); diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 5ad566a..91c7928 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -13,11 +13,15 @@ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-ho import { ExecuteToolScope, + ExecuteToolCallArguments, + ExecuteToolCallResult, InvokeAgentScope, InferenceScope, OutputScope, OpenTelemetryScope, OpenTelemetryConstants, + ToolCallAction, + ToolCallOutcomeStatus, } from "../../../../src/a365/index.js"; import type { AgentDetails, @@ -524,6 +528,10 @@ describe("Scopes", () => { key: OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, val: "10.0.0.10", }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, + val: '{"param": "value"}', + }), ]), ); @@ -561,6 +569,10 @@ describe("Scopes", () => { key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: "https://web.link", }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, + val: '{"result":"Tool result"}', + }), ]), ); scope?.dispose(); @@ -1507,6 +1519,10 @@ describe("Request content and message serialization (span attributes)", () => { }); describe("ExecuteToolScope – tool args and response serialization", () => { + const serializationError = + '{"serialization_error":"Failed to serialize execute tool payload."}'; + const legacySerializationError = '{"error":"serialization failed"}'; + it("should serialize object arguments to span attribute", () => { const objArgs = { query: "GDPR", maxResults: 5 }; const scope = ExecuteToolScope.start( @@ -1531,6 +1547,186 @@ describe("Request content and message serialization (span attributes)", () => { JSON.stringify(objResponse), ); }); + + it("should serialize typed arguments with schema version, nested values, and extension fields", () => { + const typedArgs = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + parameters: { + query: "GDPR", + filters: { sensitivity: "high", includeArchived: true }, + }, + resources: [ + { + id: "doc-1", + type: "document", + provider: "sharepoint", + extension_data: { provider_resource_type: "page" }, + }, + ], + extension_data: { request_context: { scenario: "enterprise-search" } }, + }); + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "search", arguments: typedArgs }, + testAgentDetails, + ); + scope.dispose(); + + const parsed = JSON.parse( + getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY] as string, + ); + expect(parsed.schema_version).toBe("1.0"); + expect(parsed.action).toBe("read"); + expect(parsed.parameters.filters).toEqual({ + sensitivity: "high", + includeArchived: true, + }); + expect(parsed.resources[0].metadata.provider_resource_type).toBe("page"); + expect(parsed.metadata.request_context).toEqual({ scenario: "enterprise-search" }); + }); + + it("should serialize typed results with nested outcome and extension fields", () => { + const typedResult = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + message: "Fetched 1 document", + provider_code: "OK", + extension_data: { retryable: false }, + }, + resources: [ + { + id: "doc-1", + type: "document", + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + message: "available", + extension_data: { provider_status: "complete" }, + }, + data: { title: "Doc A" }, + extension_data: { relevance_score: 0.95 }, + }, + ], + pagination: { + has_more: false, + total_count: 1, + extension_data: { request_charge: 3 }, + }, + extension_data: { source_trace: { provider: "sharepoint" } }, + }); + + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(typedResult); + scope.dispose(); + + const parsed = JSON.parse( + getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY] as string, + ); + expect(parsed.schema_version).toBe("1.0"); + expect(parsed.outcome).toEqual({ + status: "success", + message: "Fetched 1 document", + provider_code: "OK", + metadata: { retryable: false }, + }); + expect(parsed.resources[0].outcome.metadata.provider_status).toBe("complete"); + expect(parsed.resources[0].metadata.relevance_score).toBe(0.95); + expect(parsed.pagination).toEqual({ + has_more: false, + total_count: 1, + metadata: { request_charge: 3 }, + }); + expect(parsed.metadata.source_trace).toEqual({ provider: "sharepoint" }); + }); + + it("should omit the typed result attribute when response is undefined", () => { + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(undefined); + scope.dispose(); + + expect( + getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY], + ).toBeUndefined(); + }); + + it("should omit the typed result attribute when response is null", () => { + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(null); + scope.dispose(); + + expect( + getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY], + ).toBeUndefined(); + }); + + it("should preserve the legacy fallback for circular object arguments", () => { + const circular: Record = { query: "GDPR" }; + circular.self = circular; + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "search", arguments: circular }, + testAgentDetails, + ); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY]).toBe( + legacySerializationError, + ); + }); + + it("should use the typed fallback for circular ExecuteToolCallArguments instances", () => { + const extension_data: Record = {}; + const typedArgs = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + parameters: { query: "GDPR" }, + extension_data, + }); + extension_data.self = typedArgs; + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "search", arguments: typedArgs }, + testAgentDetails, + ); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY]).toBe( + serializationError, + ); + }); + + it("should preserve the legacy fallback for circular object responses", () => { + const circular: Record = { results: ["Doc A"] }; + circular.self = circular; + + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(circular); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY]).toBe( + legacySerializationError, + ); + }); + + it("should use the typed fallback for circular ExecuteToolCallResult instances", () => { + const data: Record = {}; + const typedResult = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + }, + data, + }); + data.self = data; + + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(typedResult); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY]).toBe( + serializationError, + ); + }); }); });