From 922922e4ecd6ade8cc6f92fcec7a2faf1f23814c Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 07:06:09 -0600 Subject: [PATCH 1/3] feat(a365): add typed execute tool schemas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- .github/workflows/pr-validation.yml | 3 + A365_DOCUMENTATION.md | 67 +++++- CHANGELOG.md | 3 + package.json | 1 + src/a365/contracts.ts | 24 ++- src/a365/index.ts | 14 ++ src/a365/message-utils.ts | 36 +++- src/a365/scopes/ExecuteToolScope.ts | 17 +- src/a365/tool-call-models.ts | 182 +++++++++++++++++ src/index.ts | 14 ++ .../unit/a365/executeToolJsonModels.test.ts | 166 +++++++++++++++ test/internal/unit/a365/messageUtils.test.ts | 92 ++++++++- test/internal/unit/a365/scopes.test.ts | 192 ++++++++++++++++++ tsconfig.type-tests.json | 7 + 14 files changed, 809 insertions(+), 9 deletions(-) create mode 100644 src/a365/tool-call-models.ts create mode 100644 test/internal/unit/a365/executeToolJsonModels.test.ts create mode 100644 tsconfig.type-tests.json diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 32a41d56..1ff57a4a 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -27,6 +27,9 @@ jobs: - name: Build run: npm run build + - name: Type-check test sources + run: npm run typecheck:test + - name: Format check run: npm run format diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 7b041e07..64479e2a 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( @@ -30,9 +35,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", + }, + custom_resource_field: "kept", + }, + ], + parameters: { query: "hello", includeArchived: false }, + custom_argument_field: "kept", + }); + const toolScope = ExecuteToolScope.start( { conversationId: "conv-123" }, - { toolName: "Search", input: { query: "hello" } }, + { + toolName: "Search", + arguments: toolArguments, + toolCallId: "tool-call-123", + toolType: "function", + }, { agentId: "agent-1", tenantId: "tenant-1" }, ); @@ -42,6 +74,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 }, + custom_result_field: "kept", + }, + ], + pagination: { has_more: false, total_count: 1 }, + custom_result_field: "kept", + }), + ); + toolScope.dispose(); inferenceScope.dispose(); }); @@ -49,6 +111,9 @@ invokeScope.run(async () => { 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. + ## Baggage And Context Use `BaggageBuilder` when you want tenant, agent, user, conversation, or session data to flow with the active context. diff --git a/CHANGELOG.md b/CHANGELOG.md index d61c5f77..2411433c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Other Changes - Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. +### Features Added +- Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". + ## [1.4.0] - 2026-09-08 ### Features Added diff --git a/package.json b/package.json index f6edea07..60a85055 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:unit": "vitest run --config vitest.unit.config.ts", "test:functional": "vitest run --config vitest.functional.config.ts", "test:integration": "node --test test/integration/*.test.mjs", + "typecheck:test": "tsc -p tsconfig.type-tests.json --noEmit", "test:performance": "node --expose-gc perf/benchmark.mjs", "test:esm-build": "node --input-type=module -e \"import('./dist/esm/distro/instrumentations.js').then(()=>console.log('esm import ok')).catch((e)=>{console.error(e);process.exit(1);})\"", "test:watch": "vitest", diff --git a/src/a365/contracts.ts b/src/a365/contracts.ts index 39bcba91..6c67ffd0 100644 --- a/src/a365/contracts.ts +++ b/src/a365/contracts.ts @@ -9,6 +9,26 @@ */ 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 { + ToolCallIdentifier, + ToolCallContainer, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultSensitivity, + ToolCallResultPolicy, + ToolCallResultSecurity, + ToolCallResultPagination, + ToolCallResultResource, +} from "./tool-call-models.js"; // --------------------------------------------------------------------------- // Default finish reason (per OTel spec) @@ -373,8 +393,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 9ae02297..f671efdb 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, @@ -55,6 +60,15 @@ export type { ToolCallRequestPart, ToolCallResponsePart, ReasoningPart, + 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 0b76af69..1263337b 100644 --- a/src/a365/message-utils.ts +++ b/src/a365/message-utils.ts @@ -15,7 +15,21 @@ import type { InputMessagesParam, OutputMessagesParam, } from "./contracts.js"; -import { MessageRole, DEFAULT_FINISH_REASON } from "./contracts.js"; +import { + DEFAULT_FINISH_REASON, + ExecuteToolCallArguments, + ExecuteToolCallResult, + MessageRole, +} from "./contracts.js"; + +const EXECUTE_TOOL_SERIALIZATION_ERROR = + '{"serialization_error":"Failed to serialize execute tool payload."}'; + +function isTypedExecuteToolPayload( + value: object, +): value is ExecuteToolCallArguments | ExecuteToolCallResult { + return value instanceof ExecuteToolCallArguments || value instanceof ExecuteToolCallResult; +} /** * Type guard that returns `true` when the input is a structured wrapper @@ -105,6 +119,26 @@ 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 JSON.stringify(value) ?? EXECUTE_TOOL_SERIALIZATION_ERROR; + } catch { + return EXECUTE_TOOL_SERIALIZATION_ERROR; + } +} + /** * Ensures the value is always a JSON-parseable string. * - Objects are serialized via JSON.stringify. diff --git a/src/a365/scopes/ExecuteToolScope.ts b/src/a365/scopes/ExecuteToolScope.ts index 84b4b908..380a1589 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); @@ -87,10 +90,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 00000000..eeeb1423 --- /dev/null +++ b/src/a365/tool-call-models.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** 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", +} + +/** Resource identifier details for an execute tool call. */ +export interface ToolCallIdentifier { + /** Identifier type. */ + type?: string; + /** Identifier value. */ + value?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Container metadata for a resource reference. */ +export interface ToolCallContainer { + /** Container identifier. */ + id?: string; + /** Container URI. */ + uri?: string; + /** Container type. */ + type?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Resource metadata for an execute tool call. */ +export interface ToolCallResource { + /** 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; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Outcome details for an execute tool call result. */ +export interface ToolCallResultOutcome { + /** 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; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Sensitivity metadata for a tool call result. */ +export interface ToolCallResultSensitivity { + /** Sensitivity label identifier. */ + label_id?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Policy metadata for a tool call result. */ +export interface ToolCallResultPolicy { + /** Policy decision for the tool call. */ + decision?: ToolPolicyDecision; + /** Policy identifier. */ + id?: string; + /** Policy name. */ + name?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Security metadata for a tool call result. */ +export interface ToolCallResultSecurity { + /** Whether XPIA was detected. */ + xpia_detected?: boolean; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Pagination metadata for a tool call result. */ +export interface ToolCallResultPagination { + /** 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; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** 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 { + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; + + /** Schema version for this payload. */ + schema_version: string; + /** Resources referenced by the tool call. */ + resources?: ToolCallResource[]; + /** Requested action for the tool call. */ + action?: ToolCallAction; + /** Tool parameters for the call. */ + parameters?: Record; + + constructor(init: Partial = {}) { + Object.assign(this, init); + this.schema_version = init.schema_version ?? "1.0"; + } +} + +/** Structured result for an execute tool call. */ +export class ExecuteToolCallResult { + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; + + /** Schema version for this payload. */ + schema_version: string; + /** Overall tool call outcome. */ + outcome?: ToolCallResultOutcome; + /** Resources returned by the tool call. */ + resources?: ToolCallResultResource[]; + /** Tool result data. */ + data?: Record; + /** Pagination metadata for the result set. */ + pagination?: ToolCallResultPagination; + + constructor(init: Partial = {}) { + Object.assign(this, init); + this.schema_version = init.schema_version ?? "1.0"; + } +} diff --git a/src/index.ts b/src/index.ts index a2869127..f1e71b1d 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, @@ -90,6 +95,15 @@ export type { ToolCallRequestPart, ToolCallResponsePart, ReasoningPart, + ToolCallIdentifier, + ToolCallContainer, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultSensitivity, + ToolCallResultPolicy, + ToolCallResultSecurity, + ToolCallResultPagination, + ToolCallResultResource, HeadersCarrier, GuardrailDetails, GuardrailFinding, diff --git a/test/internal/unit/a365/executeToolJsonModels.test.ts b/test/internal/unit/a365/executeToolJsonModels.test.ts new file mode 100644 index 00000000..40f3bb75 --- /dev/null +++ b/test/internal/unit/a365/executeToolJsonModels.test.ts @@ -0,0 +1,166 @@ +// 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 } from "../../../../src/a365/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); + }); + + 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", provider_code: "sp" }], + container: { + id: "folder-1", + uri: "https://contoso.example/folders/1", + type: "folder", + label_id: "container-label", + }, + custom_resource_field: true, + }, + ], + parameters: { query: "plan" }, + top_level_extra: "kept", + }); + + expect(defaultArgs).toMatchObject({ + schema_version: "1.0", + action: "read", + resources: [ + { + identifiers: [{ type: "driveItem", value: "1", provider_code: "sp" }], + container: { label_id: "container-label" }, + custom_resource_field: true, + }, + ], + top_level_extra: "kept", + }); + + const explicitArgs = new a365.ExecuteToolCallArguments({ schema_version: "2.0" }); + expect(explicitArgs.schema_version).toBe("2.0"); + }); + + 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 | 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", 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", sensitivity_extra: "kept" }, + policy: { + decision: a365.ToolPolicyDecision.ALLOW, + id: "policy-1", + name: "AllowPolicy", + }, + security: { xpia_detected: true }, + data: { skipped: 1 }, + resource_extra: "kept", + }, + ], + data: { documents: 1 }, + pagination: { has_more: true, next_cursor: "cursor-2", total_count: 10 }, + 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", sensitivity_extra: "kept" }, + policy: { decision: "allow" }, + security: { xpia_detected: true }, + resource_extra: "kept", + }, + ], + pagination: { has_more: true, next_cursor: "cursor-2", total_count: 10 }, + result_extra: "kept", + }); + + const explicitResult = new a365.ExecuteToolCallResult({ schema_version: "2.1" }); + expect(explicitResult.schema_version).toBe("2.1"); + }); +}); diff --git a/test/internal/unit/a365/messageUtils.test.ts b/test/internal/unit/a365/messageUtils.test.ts index 7430cada..52a77f1f 100644 --- a/test/internal/unit/a365/messageUtils.test.ts +++ b/test/internal/unit/a365/messageUtils.test.ts @@ -3,7 +3,14 @@ import { describe, it, expect } from "vitest"; -import { MessageRole, Modality } from "../../../../src/a365/contracts.js"; +import { + ExecuteToolCallArguments, + ExecuteToolCallResult, + MessageRole, + Modality, + ToolCallAction, + ToolCallOutcomeStatus, +} from "../../../../src/a365/contracts.js"; import type { InputMessages, OutputMessages } from "../../../../src/a365/contracts.js"; import { isWrappedMessages, @@ -12,6 +19,7 @@ import { normalizeInputMessages, normalizeOutputMessages, serializeMessages, + serializeToolPayload, } from "../../../../src/a365/message-utils.js"; describe("isWrappedMessages", () => { @@ -318,3 +326,85 @@ 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 typed payloads with schema version, nested values, and extension fields", () => { + const payload = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + parameters: { + query: "GDPR", + filters: { sensitivity: "high", includeArchived: true }, + }, + resources: [ + { + id: "doc-1", + type: "document", + provider: "sharepoint", + provider_resource_type: "page", + }, + ], + request_context: { scenario: "enterprise-search" }, + }); + + const serialized = serializeToolPayload(payload); + const parsed = JSON.parse(serialized 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].provider_resource_type).toBe("page"); + expect(parsed.request_context).toEqual({ scenario: "enterprise-search" }); + }); + + 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 payload = new ExecuteToolCallArguments({ action: ToolCallAction.READ }); + payload.self = payload; + + expect(serializeToolPayload(payload)).toBe(serializationError); + }); + + it("returns the exact fallback for circular ExecuteToolCallResult payloads", () => { + const result = new ExecuteToolCallResult({ + outcome: { status: ToolCallOutcomeStatus.SUCCESS }, + data: { count: 1 }, + }); + result.self = result; + + expect(serializeToolPayload(result)).toBe(serializationError); + }); + + it("returns the exact fallback for bigint payloads", () => { + expect( + serializeToolPayload( + new ExecuteToolCallArguments({ action: ToolCallAction.READ, count: BigInt(1) }), + ), + ).toBe(serializationError); + }); + + it("returns the exact fallback when payload serialization throws", () => { + const payload = new ExecuteToolCallArguments({ action: ToolCallAction.READ }); + payload.toJSON = () => { + throw new Error("boom"); + }; + + expect(serializeToolPayload(payload)).toBe(serializationError); + }); +}); diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 4c51f71e..5be6fcd5 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, @@ -521,6 +525,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"}', + }), ]), ); @@ -558,6 +566,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(); @@ -1245,6 +1257,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( @@ -1269,6 +1285,182 @@ 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", + provider_resource_type: "page", + }, + ], + 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].provider_resource_type).toBe("page"); + expect(parsed.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", + retryable: false, + }, + resources: [ + { + id: "doc-1", + type: "document", + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + message: "available", + provider_status: "complete", + }, + data: { title: "Doc A" }, + relevance_score: 0.95, + }, + ], + pagination: { + has_more: false, + total_count: 1, + request_charge: 3, + }, + 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", + retryable: false, + }); + expect(parsed.resources[0].outcome.provider_status).toBe("complete"); + expect(parsed.resources[0].relevance_score).toBe(0.95); + expect(parsed.pagination).toEqual({ + has_more: false, + total_count: 1, + request_charge: 3, + }); + expect(parsed.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 typedArgs = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + parameters: { query: "GDPR" }, + }); + typedArgs.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 typedResult = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + }, + }); + typedResult.self = typedResult; + + 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, + ); + }); }); }); diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json new file mode 100644 index 00000000..41ee652d --- /dev/null +++ b/tsconfig.type-tests.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.test.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["src", "test/internal/unit/a365/executeToolJsonModels.test.ts"] +} From 8f820e2db6508c801379399d124c7978fa049f03 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 07:06:50 -0600 Subject: [PATCH 2/3] docs: link ExecuteTool changelog entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2411433c..c8a874c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. ### Features Added -- Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". +- Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". [#240](https://github.com/microsoft/opentelemetry-distro-javascript/pull/240) ## [1.4.0] - 2026-09-08 From c3250ee2b1f7d961e8e577e89d6ab0ed13284dfa Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 15:53:19 -0600 Subject: [PATCH 3/3] docs: normalize ExecuteTool changelog placement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a874c1..8b8990c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,12 @@ ## [Unreleased] -### Other Changes -- Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. - ### Features Added - Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". [#240](https://github.com/microsoft/opentelemetry-distro-javascript/pull/240) +### Other Changes +- Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. + ## [1.4.0] - 2026-09-08 ### Features Added