diff --git a/nodejs/openai/multi-agent-sample/.env.template b/nodejs/openai/multi-agent-sample/.env.template new file mode 100644 index 00000000..23a0cad1 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/.env.template @@ -0,0 +1,59 @@ +# ============================================================================= +# SERVER PORTS +# ============================================================================= +ORCHESTRATOR_PORT=3978 +PLANNER_PORT=4001 +EXECUTOR_PORT=4002 +REVIEWER_PORT=4003 +AGENT_HOST=localhost + +# ============================================================================= +# ENVIRONMENT +# ============================================================================= +# Set to "development" for local dev (disables JWT auth) +# Remove or set to "production" for Teams deployment +NODE_ENV=development + +# ============================================================================= +# OBSERVABILITY +# ============================================================================= +# Console exporter is the default. Set to true for A365 backend exporter. +# ENABLE_A365_OBSERVABILITY_EXPORTER=false + +# ============================================================================= +# AUTHENTICATION — LOCAL DEVELOPMENT +# ============================================================================= +# Bearer token for local testing (optional, used when NODE_ENV=development) +# BEARER_TOKEN=<> + +# ============================================================================= +# AUTHENTICATION — TEAMS / PRODUCTION DEPLOYMENT +# ============================================================================= +# Service Connection (Azure AD App Registration for your Agent Blueprint) +connections__service_connection__settings__clientId=<> +connections__service_connection__settings__clientSecret=<> +connections__service_connection__settings__tenantId=<> + +# Connection mapping (routes all service URLs through the service connection) +connectionsMap__0__serviceUrl=* +connectionsMap__0__connection=service_connection + +# Agentic Authentication +agentic_type=agentic +agentic_altBlueprintConnectionName=service_connection +agentic_scopes=ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default + +# Auth Handler +AUTH_HANDLER_NAME=agentic +USE_AGENTIC_AUTH=true + +# ============================================================================= +# SUB-AGENT AUTHENTICATION (for independent Copilot Studio deployment) +# ============================================================================= +# Each sub-agent is a Bot Framework agent that can be deployed independently +# to Copilot Studio or Teams. When deployed separately, each needs its own +# Azure AD App Registration. For local testing, NODE_ENV=development disables +# auth for all services. +# PLANNER_CLIENT_ID=<> +# EXECUTOR_CLIENT_ID=<> +# REVIEWER_CLIENT_ID=<> diff --git a/nodejs/openai/multi-agent-sample/README.md b/nodejs/openai/multi-agent-sample/README.md new file mode 100644 index 00000000..995a3e3c --- /dev/null +++ b/nodejs/openai/multi-agent-sample/README.md @@ -0,0 +1,287 @@ +# Multi-Agent Sales Campaign Demo + +This sample demonstrates **multi-agent orchestration** with Microsoft Agent 365 telemetry, designed for **local testing**. Four independently hosted agents collaborate through HTTP to execute a sales campaign, producing a clean trace in A365 observability that shows every agent-to-agent call as a distinct span. + +All outputs are stubbed so the demo runs deterministically — no LLM API key required. All four agents are Bot Framework agents (`AgentApplication` + `CloudAdapter`) that can later be deployed independently to Microsoft Teams or Copilot Studio. The orchestrator coordinates the sub-agents via direct HTTP calls (`/api/run`) during the pipeline, while each sub-agent also exposes `/api/messages` for standalone Bot Framework interaction. + +## What This Sample Demonstrates + +- **Multi-agent orchestration**: An orchestrator coordinates three sub-agents (planner, executor, reviewer), each running as a Bot Framework agent on its own port +- **Agent-to-agent (A2A) communication**: The orchestrator calls sub-agents via HTTP POST with `ExecutionType.Agent2Agent` +- **A365 observability spans**: Full parent/child span tree visible in the telemetry viewer +- **W3C trace context propagation**: Uses the SDK's built-in `injectTraceContext()` and `runWithExtractedTraceContext()` to propagate `traceparent`/`tracestate` headers so all spans appear in one trace +- **Copilot Studio ready**: Each sub-agent is a full Bot Framework agent (`AgentApplication` + `CloudAdapter` + `/api/messages`) that can be deployed independently to Teams or Copilot Studio + +## Architecture + +Each agent exposes two endpoints: `/api/messages` (Bot Framework protocol for Teams/Copilot Studio) and `/api/run` (direct HTTP for pipeline orchestration). + +``` + ┌──────────────────────────────────────────┐ + │ Orchestrator (port 3978) │ + │ /api/messages (Bot Framework) │ + │ │ + │ Sequential State Machine: │ +User ──── /api/messages ──│ Step 1: POST /api/run → Planner │ + │ Step 2: POST /api/run → Executor │ + │ Step 3: POST /api/run → Reviewer │ + │ Step 4: POST /api/run → Executor │ + │ Step 5: POST /api/run → Reviewer │ + └────┬──────────┬──────────┬──────────────┘ + │ │ │ + ┌──────────┘ ┌─────┘ ┌─────┘ + ▼ ▼ ▼ + ┌─────────────┐ ┌───────────┐ ┌───────────┐ + │ Planner │ │ Executor │ │ Reviewer │ + │ (port 4001)│ │ (port 4002)│ │ (port 4003)│ + │ /api/run │ │ /api/run │ │ /api/run │ + │ /api/msgs │ │ /api/msgs │ │ /api/msgs │ + └─────────────┘ └───────────┘ └───────────┘ +``` + +## Telemetry Span Tree + +All spans share a single **trace ID** linked by W3C `traceparent` header propagation across the 4 separate processes. The SDK's `injectTraceContext()` and `runWithExtractedTraceContext()` handle this automatically. + +### Expected span tree + +``` +invoke_agent (root – orchestrator) [a365.run_id, a365.scenario] +├─ invoke_agent (planner) [a365.agent.role=planner, step=1] +│ └─ Chat stubbed-planner [InferenceScope] +├─ invoke_agent (executor – draft) [a365.agent.role=executor, step=2] +│ ├─ Chat stubbed-executor [InferenceScope] +│ │ ├─ execute_tool crm.searchContacts [ExecuteToolScope] +│ │ └─ execute_tool crm.createCampaign [ExecuteToolScope] +├─ invoke_agent (reviewer – BLOCK) [a365.review.status=blocked, step=3] +│ └─ Chat stubbed-reviewer [InferenceScope] +├─ invoke_agent (executor – fix) [a365.agent.role=executor, step=4] +│ ├─ Chat stubbed-executor [InferenceScope] +│ │ └─ execute_tool crm.createActivities [ExecuteToolScope] +└─ invoke_agent (reviewer – APPROVE) [a365.review.status=approved, step=5] + └─ Chat stubbed-reviewer [InferenceScope] +``` + +### Example trace output (real span IDs) + +Below is the actual trace map from a local test run. Every span shares trace ID `4ab50a2cddc126a967e3e9e19d4fba4e`, proving W3C context propagation works across 4 separate processes: + +``` +Trace: 4ab50a2cddc126a967e3e9e19d4fba4e Run: run-1772582956532 + +[orch] invoke_agent "Sales Campaign Orchestrator" (root span) +│ +├─[orch] invoke_agent planner (spanId: 948988dc3d719c69) +│ └─[plan] Chat stubbed-planner (id: 0fbdf6d70bb489de, parent: 948988dc3d719c69, remote: true) +│ gen_ai.agent.id=planner-agent a365.step=1 tokens: 120→85 +│ +├─[orch] invoke_agent executor-draft (spanId: 98d7ee62f2d8a6ae) +│ └─[exec] Chat stubbed-executor (id: 1fa20b51edf4a01e, parent: 98d7ee62f2d8a6ae, remote: true) +│ │ gen_ai.agent.id=executor-agent a365.step=2 tokens: 200→150 +│ ├─[exec] execute_tool crm.searchContacts (id: ed64543e20487a41, parent: 1fa20b51edf4a01e) +│ │ 50 contacts returned +│ └─[exec] execute_tool crm.createCampaign (id: 1c4cf7a2d8e385c9, parent: 1fa20b51edf4a01e) +│ campaign: cmp-demo-001 +│ +├─[orch] invoke_agent reviewer-BLOCK (spanId: 34127cbc6e1f2f51) +│ └─[rev] Chat stubbed-reviewer (id: d38121f2866c5f37, parent: 34127cbc6e1f2f51, remote: true) +│ a365.review.status=blocked a365.step=3 tokens: 250→90 +│ reason: "Missing GDPR opt-out link" +│ +├─[orch] invoke_agent executor-fix (spanId: 7222f050af92d767) +│ └─[exec] Chat stubbed-executor (id: 7cf90b708a331a5c, parent: 7222f050af92d767, remote: true) +│ │ gen_ai.agent.id=executor-agent a365.step=4 tokens: 180→120 +│ └─[exec] execute_tool crm.createActivities (id: c8dc4e7cd99dc736, parent: 7cf90b708a331a5c) +│ 150 activities created +│ +└─[orch] invoke_agent reviewer-APPROVE (spanId: b393aadc2cdc4661) + └─[rev] Chat stubbed-reviewer (id: f633f7473b619296, parent: b393aadc2cdc4661, remote: true) + a365.review.status=approved a365.step=5 tokens: 250→90 + reason: "All GDPR compliance requirements met" +``` + +Key observations from the trace: +- **Single trace ID** (`4ab50a2c...`) — all 7 sub-agent spans + orchestrator spans share one trace +- **`remote: true`** on parent context — confirms spans crossed HTTP service boundaries via `traceparent` header +- **Parent-child linkage** — each `Chat` span's parent matches the orchestrator's `invoke_agent` span ID +- **Tool spans nest under inference** — `execute_tool` spans are children of their `Chat` span, not siblings +- **4 separate PIDs** — spans come from 4 independent Node.js processes (orchestrator, planner, executor, reviewer) + +### Example span object (console exporter) + +Each span printed to console looks like this (planner example): + +```json +{ + "instrumentationScope": { "name": "Agent365Sdk" }, + "traceId": "4ab50a2cddc126a967e3e9e19d4fba4e", + "parentSpanContext": { + "traceId": "4ab50a2cddc126a967e3e9e19d4fba4e", + "spanId": "948988dc3d719c69", + "traceFlags": 1, + "isRemote": true + }, + "name": "Chat stubbed-planner", + "id": "0fbdf6d70bb489de", + "kind": 2, + "attributes": { + "gen_ai.system": "az.ai.agent365", + "gen_ai.operation.name": "Chat", + "gen_ai.agent.id": "planner-agent", + "gen_ai.agent.name": "Planner Agent", + "gen_ai.request.model": "stubbed-planner", + "gen_ai.conversation.id": "run-1772582956532", + "tenant.id": "demo-tenant", + "correlation.id": "corr-1772582956304", + "session.description": "Multi-agent sales campaign pipeline", + "a365.agent.role": "planner", + "a365.step": 1, + "a365.run_id": "run-1772582956532", + "gen_ai.usage.input_tokens": "120", + "gen_ai.usage.output_tokens": "85", + "gen_ai.response.finish_reasons": "stop", + "gen_ai.input.messages": "[\"...\"]", + "gen_ai.output.messages": "[\"...\"]" + }, + "resource": { + "attributes": { + "service.name": "Multi-Agent Planner-1.0.0", + "host.name": "pefan4-0", + "process.pid": 88752 + } + } +} +``` + +## Prerequisites + +- **Node.js** 18+ (for built-in `fetch`) +- **npm** or **yarn** + +No LLM API key is needed — all agent logic uses deterministic stubs. + +## Configuration + +Copy the template and adjust if needed: + +```bash +cp .env.template .env +``` + +Default ports: +| Service | Port | +|---|---| +| Orchestrator | 3978 | +| Planner | 4001 | +| Executor | 4002 | +| Reviewer | 4003 | + +## How to Run + +### Step 1: Install dependencies + +```bash +npm install +``` + +### Step 2: Configure environment + +```bash +cp .env.template .env +``` + +Ensure `.env` has `NODE_ENV=development` (this disables JWT auth for local testing). + +### Step 3: Start all services + +```bash +npm run dev +``` + +All four services start simultaneously via `concurrently`. You should see: + +``` +[orch] [Orchestrator] listening on localhost:3978 for appId undefined +[plan] [Planner] listening on localhost:4001 for appId undefined +[exec] [Executor] listening on localhost:4002 for appId undefined +[rev] [Reviewer] listening on localhost:4003 for appId undefined +``` + +The `appId undefined` is expected in development mode (no Azure AD credentials configured). + +### Step 4: Send a message via Agents Playground + +In a separate terminal (while `npm run dev` is running): + +```bash +npm run test-tool +``` + +This launches the Agents Playground UI in your browser. Set the bot endpoint URL to `http://localhost:3978/api/messages` (or the port set by `ORCHESTRATOR_PORT` in `.env`) and send a message: + +> Launch a Q1 EMEA enterprise sales campaign targeting accounts with 500+ employees + +The orchestrator runs the 5-step pipeline and returns a formatted result. The console output shows the pipeline progress: + +``` +[orch] [Orchestrator] Starting pipeline run-1772582956532 +[orch] [Orchestrator] Step 1/5: Calling Planner... +[plan] [Planner] Step 1: Generated campaign plan for "Enterprise accounts in EMEA with >500 employees" +[orch] [Orchestrator] Step 2/5: Calling Executor (draft)... +[exec] [Executor] Step 2: Draft — 50 contacts, campaign "cmp-demo-001" +[orch] [Orchestrator] Step 3/5: Calling Reviewer (round 1)... +[rev] [Reviewer] Step 3: Round 1 — BLOCKED +[orch] [Orchestrator] Step 4/5: Calling Executor (fix)... +[exec] [Executor] Step 4: Fix — created 150 activities +[orch] [Orchestrator] Step 5/5: Calling Reviewer (round 2)... +[rev] [Reviewer] Step 5: Round 2 — APPROVED +[orch] [Orchestrator] Pipeline run-1772582956532 complete! +``` + +### Step 5: View telemetry spans + +After the pipeline completes, the console exporter prints OTel span objects from each service. These spans contain all the data needed to reconstruct the trace tree in a telemetry viewer. + +### Alternative: Test sub-agents directly with curl + +```bash +# Health check +curl http://localhost:3978/api/health + +# Test a sub-agent directly (bypasses orchestrator) +curl -X POST http://localhost:4001/api/run \ + -H "Content-Type: application/json" \ + -d '{"runId":"test-001","step":1,"payload":{"request":"test campaign"}}' +``` + +### Build for production + +```bash +npm run build +npm start +``` + +### Stop all services + +Press `Ctrl+C` in the terminal running `npm run dev`. + +## Scenario Walkthrough + +1. **Planner** (Step 1): Receives the campaign brief and returns a plan targeting "Enterprise accounts in EMEA with >500 employees" via email, LinkedIn, and webinar channels. + +2. **Executor — Draft** (Step 2): Searches the CRM for 50 matching contacts and creates campaign `cmp-demo-001`. + +3. **Reviewer — BLOCK** (Step 3): Reviews the draft and blocks it because the email template is missing a GDPR opt-out link. + +4. **Executor — Fix** (Step 4): Applies the required fixes and creates 150 follow-up activities (3 touches per contact) with opt-out links. + +5. **Reviewer — APPROVE** (Step 5): Verifies compliance and approves the campaign for launch. + +## Deploy to Microsoft Teams / Copilot Studio + +> **TBD** — Detailed deployment instructions will be added in a future update. All four agents are Bot Framework agents with `AgentApplication`, `CloudAdapter`, and `/api/messages` — each can be deployed independently to Teams or Copilot Studio with its own Azure AD App Registration. See `.env.template` for auth configuration. + +## Deploy to Production + +> **TBD** — Production deployment guide (Azure App Service, Container Apps) will be added in a future update. + diff --git a/nodejs/openai/multi-agent-sample/manifest/agenticUserTemplateManifest.json b/nodejs/openai/multi-agent-sample/manifest/agenticUserTemplateManifest.json new file mode 100644 index 00000000..3023c243 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/manifest/agenticUserTemplateManifest.json @@ -0,0 +1,6 @@ +{ + "id": "<>", + "schemaVersion": "0.1.0-preview", + "agentIdentityBlueprintId": "<>", + "communicationProtocol": "activityProtocol" +} diff --git a/nodejs/openai/multi-agent-sample/manifest/color.png b/nodejs/openai/multi-agent-sample/manifest/color.png new file mode 100644 index 00000000..760f6d54 Binary files /dev/null and b/nodejs/openai/multi-agent-sample/manifest/color.png differ diff --git a/nodejs/openai/multi-agent-sample/manifest/manifest.json b/nodejs/openai/multi-agent-sample/manifest/manifest.json new file mode 100644 index 00000000..d62b2fe0 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/manifest/manifest.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json", + "id": "<>", + "name": { + "short": "Sales Campaign Orchestrator", + "full": "Multi-Agent Sales Campaign Orchestrator" + }, + "description": { + "short": "Orchestrates a multi-agent sales campaign pipeline with A365 telemetry.", + "full": "A multi-agent orchestrator that drives a sales campaign through Planner, Executor, and Reviewer agents. Demonstrates embodied agent-to-agent communication with full A365 observability span tree." + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#07687d", + "version": "1.0.0", + "manifestVersion": "devPreview", + "developer": { + "name": "Agent Developer", + "mpnId": "", + "websiteUrl": "https://go.microsoft.com/fwlink/?LinkId=518021", + "privacyUrl": "https://go.microsoft.com/fwlink/?LinkId=518021", + "termsOfUseUrl": "https://shares.datatransfer.microsoft.com/assets/Microsoft_Terms_of_Use.html" + }, + "agenticUserTemplates": [ + { + "id": "<>", + "file": "agenticUserTemplateManifest.json" + } + ] +} diff --git a/nodejs/openai/multi-agent-sample/manifest/outline.png b/nodejs/openai/multi-agent-sample/manifest/outline.png new file mode 100644 index 00000000..8962a030 Binary files /dev/null and b/nodejs/openai/multi-agent-sample/manifest/outline.png differ diff --git a/nodejs/openai/multi-agent-sample/package.json b/nodejs/openai/multi-agent-sample/package.json new file mode 100644 index 00000000..d52f215a --- /dev/null +++ b/nodejs/openai/multi-agent-sample/package.json @@ -0,0 +1,37 @@ +{ + "name": "multi-agent-sales-campaign", + "version": "1.0.0", + "description": "Multi-agent sales campaign demo with A365 telemetry showing agent-to-agent spans", + "main": "dist/orchestrator/index.js", + "type": "commonjs", + "scripts": { + "build": "tsc", + "start": "concurrently -n orch,plan,exec,rev -c blue,green,yellow,magenta \"node dist/orchestrator/index.js\" \"node dist/planner/index.js\" \"node dist/executor/index.js\" \"node dist/reviewer/index.js\"", + "dev": "concurrently -n orch,plan,exec,rev -c blue,green,yellow,magenta \"ts-node src/orchestrator/index.ts\" \"ts-node src/planner/index.ts\" \"ts-node src/executor/index.ts\" \"ts-node src/reviewer/index.ts\"", + "test-tool": "agentsplayground", + "clean": "rimraf dist node_modules package-lock.json", + "build:start": "npm run build && npm start" + }, + "keywords": [], + "license": "MIT", + "dependencies": { + "@microsoft/agents-hosting": "^1.2.2", + "@microsoft/agents-activity": "^1.2.2", + "@microsoft/agents-a365-observability": "^0.1.0-preview.30", + "@microsoft/agents-a365-observability-hosting": "^0.1.0-preview.64", + "@microsoft/agents-a365-runtime": "^0.1.0-preview.30", + "@opentelemetry/api": "^1.9.0", + "dotenv": "^17.2.2", + "express": "^5.1.0", + "concurrently": "^9.0.0" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.18", + "@types/express": "^4.17.21", + "@types/node": "^20.14.9", + "nodemon": "^3.1.10", + "rimraf": "^5.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.2" + } +} diff --git a/nodejs/openai/multi-agent-sample/src/executor/agent.ts b/nodejs/openai/multi-agent-sample/src/executor/agent.ts new file mode 100644 index 00000000..8a4fdaaf --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/executor/agent.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { configDotenv } from 'dotenv'; +configDotenv(); + +import { TurnState, AgentApplication, TurnContext, MemoryStorage } from '@microsoft/agents-hosting'; +import { ActivityTypes } from '@microsoft/agents-activity'; +import { BaggageBuilder } from '@microsoft/agents-a365-observability'; +import { BaggageBuilderUtils } from '@microsoft/agents-a365-observability-hosting'; +import { handleExecutorRequest } from './handler'; + +export class ExecutorAgent extends AgentApplication { + static authHandlerName = 'agentic'; + + constructor() { + super({ + startTypingTimer: true, + storage: new MemoryStorage(), + authorization: { + agentic: { + type: 'agentic', + }, + }, + }); + + this.onActivity(ActivityTypes.Message, async (context: TurnContext, _state: TurnState) => { + await this.handleMessage(context); + }, [ExecutorAgent.authHandlerName]); + } + + private async handleMessage(turnContext: TurnContext): Promise { + const userMessage = turnContext.activity.text?.trim() || ''; + + if (!userMessage) { + await turnContext.sendActivity('Send me a campaign plan and I\'ll execute it.'); + return; + } + + const baggageScope = BaggageBuilderUtils.fromTurnContext( + new BaggageBuilder(), + turnContext + ).sessionDescription('Executor agent') + .correlationId(`corr-${Date.now()}`) + .build(); + + try { + const result = await baggageScope.run(async () => { + return handleExecutorRequest( + { runId: `run-${Date.now()}`, step: 2, payload: { mode: 'draft', request: userMessage } }, + {} + ); + }); + await turnContext.sendActivity(JSON.stringify(result, null, 2)); + } catch (error) { + console.error('[Executor] Error:', error); + await turnContext.sendActivity(`Error: ${(error as Error).message}`); + } finally { + baggageScope.dispose(); + } + } +} + +export const agentApplication = new ExecutorAgent(); diff --git a/nodejs/openai/multi-agent-sample/src/executor/handler.ts b/nodejs/openai/multi-agent-sample/src/executor/handler.ts new file mode 100644 index 00000000..d6986b79 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/executor/handler.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InferenceScope, + InferenceDetails, + InferenceOperationType, + AgentDetails, + TenantDetails, + runWithExtractedTraceContext, +} from '@microsoft/agents-a365-observability'; +import { ExecutorOutput, AgentRequest } from '../shared/types'; +import { searchContacts, createCampaign, createActivities } from '../shared/crm-tools'; + +export async function handleExecutorRequest( + body: AgentRequest, + headers: Record +): Promise { + return runWithExtractedTraceContext(headers, async () => { + const mode = (body.payload as { mode?: string }).mode || 'draft'; + const callId = `executor-${mode}-${body.runId}`; + + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: 'stubbed-executor', + }; + const agentDetails: AgentDetails = { + agentId: 'executor-agent', + agentName: 'Executor Agent', + conversationId: body.runId, + }; + const tenantDetails: TenantDetails = { tenantId: 'demo-tenant' }; + + const scope = InferenceScope.start(inferenceDetails, agentDetails, tenantDetails); + try { + return await scope.withActiveSpanAsync(async () => { + scope.recordAttributes({ + 'a365.agent.role': 'executor', + 'a365.step': body.step, + 'a365.run_id': body.runId, + 'a365.agent.call_id': callId, + }); + + if (mode === 'draft') { + // Draft mode: search contacts and create campaign + const contacts = await searchContacts(body.runId, callId, 'Enterprise-EMEA'); + const campaign = await createCampaign(body.runId, callId, 'Q1 EMEA Outreach', contacts); + + const output: ExecutorOutput = { mode: 'draft', contacts, campaign }; + scope.recordInputMessages([JSON.stringify(body.payload)]); + scope.recordOutputMessages([JSON.stringify({ contactCount: contacts.length, campaignId: campaign.id })]); + scope.recordInputTokens(200); + scope.recordOutputTokens(150); + scope.recordFinishReasons(['stop']); + + console.log(`[Executor] Step ${body.step}: Draft — ${contacts.length} contacts, campaign "${campaign.id}"`); + return output; + } else { + // Fix mode: create activities with the applied fixes + const fixes = (body.payload as { fixes?: string[] }).fixes || []; + const activities = await createActivities(body.runId, callId, fixes); + + const output: ExecutorOutput = { mode: 'fix', activities }; + scope.recordInputMessages([JSON.stringify(body.payload)]); + scope.recordOutputMessages([JSON.stringify({ activitiesCreated: activities.length })]); + scope.recordInputTokens(180); + scope.recordOutputTokens(120); + scope.recordFinishReasons(['stop']); + + console.log(`[Executor] Step ${body.step}: Fix — created ${activities.length} activities`); + return output; + } + }); + } finally { + scope.dispose(); + } + }); +} diff --git a/nodejs/openai/multi-agent-sample/src/executor/index.ts b/nodejs/openai/multi-agent-sample/src/executor/index.ts new file mode 100644 index 00000000..5c61287f --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/executor/index.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// IMPORTANT: Load environment variables FIRST before any other imports +import { configDotenv } from 'dotenv'; +configDotenv(); + +import { AuthConfiguration, authorizeJWT, CloudAdapter, loadAuthConfigFromEnv, Request } from '@microsoft/agents-hosting'; +import express, { Response } from 'express'; +import { agentApplication } from './agent'; +import { initializeObservability } from '../shared/observability'; +import { handleExecutorRequest } from './handler'; + +initializeObservability('Multi-Agent Executor'); + +// Only NODE_ENV=development explicitly disables authentication +const isDevelopment = process.env.NODE_ENV === 'development'; +const authConfig: AuthConfiguration = isDevelopment ? {} : loadAuthConfigFromEnv(); + +console.log(`[Executor] Environment: NODE_ENV=${process.env.NODE_ENV}, isDevelopment=${isDevelopment}`); + +const server = express(); +server.use(express.json()); + +// Health endpoint — placed BEFORE auth middleware so it doesn't require authentication +server.get('/api/health', (_req, res: Response) => { + res.status(200).json({ status: 'healthy', service: 'executor', timestamp: new Date().toISOString() }); +}); + +// Pipeline endpoint — placed BEFORE auth middleware (internal orchestrator calls) +server.post('/api/run', async (req: express.Request, res: Response) => { + try { + const result = await handleExecutorRequest(req.body, req.headers); + res.json({ runId: req.body.runId, step: req.body.step, result }); + } catch (error) { + console.error('[Executor] Error:', error); + res.status(500).json({ error: (error as Error).message }); + } +}); + +server.use(authorizeJWT(authConfig)); + +// Bot Framework endpoint — for Teams / Copilot Studio deployment +server.post('/api/messages', (req: Request, res: Response) => { + const adapter = agentApplication.adapter as CloudAdapter; + adapter.process(req, res, async (context) => { + await agentApplication.run(context); + }); +}); + +const port = Number(process.env.EXECUTOR_PORT) || 4002; +const host = process.env.AGENT_HOST ?? (isDevelopment ? 'localhost' : '0.0.0.0'); +server.listen(port, host, () => { + console.log(`[Executor] listening on ${host}:${port} for appId ${authConfig.clientId}`); +}).on('error', (err: unknown) => { + console.error(err); + process.exit(1); +}).on('close', () => { + console.log('[Executor] Server closed'); + process.exit(0); +}); diff --git a/nodejs/openai/multi-agent-sample/src/orchestrator/agent-client.ts b/nodejs/openai/multi-agent-sample/src/orchestrator/agent-client.ts new file mode 100644 index 00000000..dc0ea1fa --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/orchestrator/agent-client.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InvokeAgentScope, + InvokeAgentDetails, + TenantDetails, + AgentDetails, + ExecutionType, + injectTraceContext, +} from '@microsoft/agents-a365-observability'; + +const PORTS: Record = { + planner: Number(process.env.PLANNER_PORT) || 4001, + executor: Number(process.env.EXECUTOR_PORT) || 4002, + reviewer: Number(process.env.REVIEWER_PORT) || 4003, +}; + +const TENANT: TenantDetails = { tenantId: 'demo-tenant' }; + +const CALLER_AGENT: AgentDetails = { + agentId: 'orchestrator', + agentName: 'Sales Campaign Orchestrator', +}; + +/** + * Calls a sub-agent service over HTTP with full A365 telemetry. + * Creates an InvokeAgentScope (Agent2Agent) child span and propagates W3C trace context. + */ +export async function callAgent( + agentRole: string, + runId: string, + step: number, + payload: Record +): Promise> { + const port = PORTS[agentRole]; + const host = process.env.AGENT_HOST || 'localhost'; + const url = `http://${host}:${port}/api/run`; + const callId = `${agentRole}-${step}-${runId}`; + + const invokeDetails: InvokeAgentDetails = { + agentId: `${agentRole}-agent`, + agentName: `${agentRole.charAt(0).toUpperCase() + agentRole.slice(1)} Agent`, + endpoint: { host, port }, + request: { + content: JSON.stringify(payload), + executionType: ExecutionType.Agent2Agent, + }, + }; + + const scope = InvokeAgentScope.start(invokeDetails, TENANT, CALLER_AGENT); + try { + return await scope.withActiveSpanAsync(async () => { + scope.recordAttributes({ + 'a365.agent.role': agentRole, + 'a365.agent.call_id': callId, + 'a365.step': step, + 'a365.run_id': runId, + }); + + // Inject W3C trace context into outgoing headers + const headers: Record = { + 'Content-Type': 'application/json', + }; + injectTraceContext(headers); + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ runId, step, payload }), + }); + + if (!response.ok) { + throw new Error(`Agent ${agentRole} returned HTTP ${response.status}: ${await response.text()}`); + } + + const json = await response.json() as { result: Record }; + scope.recordInputMessages([JSON.stringify(payload)]); + scope.recordOutputMessages([JSON.stringify(json.result)]); + + return json.result; + }); + } catch (error) { + scope.recordError(error as Error); + throw error; + } finally { + scope.dispose(); + } +} diff --git a/nodejs/openai/multi-agent-sample/src/orchestrator/agent.ts b/nodejs/openai/multi-agent-sample/src/orchestrator/agent.ts new file mode 100644 index 00000000..caecd2be --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/orchestrator/agent.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { configDotenv } from 'dotenv'; +configDotenv(); + +import { TurnState, AgentApplication, TurnContext, MemoryStorage } from '@microsoft/agents-hosting'; +import { ActivityTypes } from '@microsoft/agents-activity'; +import { BaggageBuilder } from '@microsoft/agents-a365-observability'; +import { AgenticTokenCacheInstance, BaggageBuilderUtils } from '@microsoft/agents-a365-observability-hosting'; +import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; +import { runSalesCampaignPipeline } from './state-machine'; + +export class OrchestratorAgent extends AgentApplication { + static authHandlerName: string = 'agentic'; + + constructor() { + super({ + startTypingTimer: true, + storage: new MemoryStorage(), + authorization: { + agentic: { + type: 'agentic', + }, + }, + }); + + this.onActivity(ActivityTypes.Message, async (context: TurnContext, _state: TurnState) => { + await this.handleMessage(context); + }, [OrchestratorAgent.authHandlerName]); + } + + private async handleMessage(turnContext: TurnContext): Promise { + const userMessage = turnContext.activity.text?.trim() || ''; + + if (!userMessage) { + await turnContext.sendActivity('Send me a campaign brief and I\'ll orchestrate the sales team agents.'); + return; + } + + // Build baggage from TurnContext (extracts real tenant/agent/caller IDs in production) + const baggageScope = BaggageBuilderUtils.fromTurnContext( + new BaggageBuilder(), + turnContext + ).sessionDescription('Multi-agent sales campaign pipeline') + .correlationId(`corr-${Date.now()}`) + .build(); + + // Preload observability token for A365 exporter + await this.preloadObservabilityToken(turnContext); + + try { + const response = await baggageScope.run(async () => { + return runSalesCampaignPipeline(userMessage, turnContext); + }); + await turnContext.sendActivity(response); + } catch (error) { + console.error('[Orchestrator] Pipeline error:', error); + await turnContext.sendActivity(`Pipeline failed: ${(error as Error).message}`); + } finally { + baggageScope.dispose(); + } + } + + /** + * Preloads or refreshes the observability token used by the A365 exporter. + * Non-fatal: if token acquisition fails, the pipeline still runs (console exporter fallback). + */ + private async preloadObservabilityToken(turnContext: TurnContext): Promise { + const agentId = turnContext?.activity?.recipient?.agenticAppId ?? ''; + const tenantId = turnContext?.activity?.recipient?.tenantId ?? ''; + + try { + await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentId, + tenantId, + turnContext, + this.authorization, + getObservabilityAuthenticationScope() + ); + } catch (error) { + console.debug('[Orchestrator] Observability token preload skipped:', (error as Error).message); + } + } +} + +export const agentApplication = new OrchestratorAgent(); diff --git a/nodejs/openai/multi-agent-sample/src/orchestrator/index.ts b/nodejs/openai/multi-agent-sample/src/orchestrator/index.ts new file mode 100644 index 00000000..52350479 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/orchestrator/index.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// IMPORTANT: Load environment variables FIRST before any other imports +import { configDotenv } from 'dotenv'; +configDotenv(); + +import { AuthConfiguration, authorizeJWT, CloudAdapter, loadAuthConfigFromEnv, Request } from '@microsoft/agents-hosting'; +import express, { Response } from 'express'; +import { agentApplication } from './agent'; +import { initializeObservability } from '../shared/observability'; + +initializeObservability('Multi-Agent Orchestrator'); + +// Only NODE_ENV=development explicitly disables authentication +const isDevelopment = process.env.NODE_ENV === 'development'; +const authConfig: AuthConfiguration = isDevelopment ? {} : loadAuthConfigFromEnv(); + +console.log(`[Orchestrator] Environment: NODE_ENV=${process.env.NODE_ENV}, isDevelopment=${isDevelopment}`); + +const server = express(); +server.use(express.json()); + +// Health endpoint — placed BEFORE auth middleware so it doesn't require authentication +server.get('/api/health', (_req, res: Response) => { + res.status(200).json({ + status: 'healthy', + service: 'orchestrator', + timestamp: new Date().toISOString(), + }); +}); + +server.use(authorizeJWT(authConfig)); + +server.post('/api/messages', (req: Request, res: Response) => { + const adapter = agentApplication.adapter as CloudAdapter; + adapter.process(req, res, async (context) => { + await agentApplication.run(context); + }); +}); + +const port = Number(process.env.ORCHESTRATOR_PORT) || 3978; +const host = process.env.AGENT_HOST ?? (isDevelopment ? 'localhost' : '0.0.0.0'); +server.listen(port, host, () => { + console.log(`[Orchestrator] listening on ${host}:${port} for appId ${authConfig.clientId}`); +}).on('error', (err: unknown) => { + console.error(err); + process.exit(1); +}).on('close', () => { + console.log('[Orchestrator] Server closed'); + process.exit(0); +}); diff --git a/nodejs/openai/multi-agent-sample/src/orchestrator/state-machine.ts b/nodejs/openai/multi-agent-sample/src/orchestrator/state-machine.ts new file mode 100644 index 00000000..dad3d038 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/orchestrator/state-machine.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InvokeAgentScope, + InvokeAgentDetails, + TenantDetails, + ExecutionType, +} from '@microsoft/agents-a365-observability'; +import { TurnContext } from '@microsoft/agents-hosting'; +import { PipelineState, PlanOutput, ExecutorOutput, ReviewOutput } from '../shared/types'; +import { callAgent } from './agent-client'; + +/** + * Runs the 5-step sales campaign pipeline: + * 1. Planner → plan + constraints + * 2. Executor (draft) → searchContacts + createCampaign + * 3. Reviewer → BLOCK (forced) + * 4. Executor (fix) → createActivities + * 5. Reviewer → APPROVE + * + * Creates a root InvokeAgentScope span that parents all agent call spans. + */ +export async function runSalesCampaignPipeline(userRequest: string, turnContext?: TurnContext): Promise { + const runId = `run-${Date.now()}`; + const state: PipelineState = { + runId, + scenario: 'sequential_sales_campaign', + teamId: 'sales-team-alpha', + userRequest, + step: 0, + }; + + console.log(`\n${'='.repeat(60)}`); + console.log(`[Orchestrator] Starting pipeline ${runId}`); + console.log(`[Orchestrator] User request: "${userRequest}"`); + console.log(`${'='.repeat(60)}\n`); + + // Extract real tenant/agent IDs from TurnContext when deployed in Teams + const tenantId = turnContext?.activity?.recipient?.tenantId ?? 'demo-tenant'; + const agentId = turnContext?.activity?.recipient?.agenticAppId + ?? turnContext?.activity?.recipient?.id + ?? 'orchestrator'; + + const tenant: TenantDetails = { tenantId }; + + const rootDetails: InvokeAgentDetails = { + agentId, + agentName: 'Sales Campaign Orchestrator', + request: { + content: userRequest, + executionType: ExecutionType.HumanToAgent, + }, + }; + + const scope = InvokeAgentScope.start(rootDetails, tenant); + let finalResponse = ''; + + try { + await scope.withActiveSpanAsync(async () => { + /* scope.recordAttributes({ + 'a365.run_id': runId, + 'a365.scenario': 'sequential_sales_campaign', + 'a365.team_id': 'sales-team-alpha', + 'a365.user_request': userRequest.substring(0, 200), + });*/ + + // Step 1: Planner + console.log('[Orchestrator] Step 1/5: Calling Planner...'); + state.step = 1; + state.plan = await callAgent('planner', runId, 1, { request: userRequest }) as unknown as PlanOutput; + + // Step 2: Executor (draft) + console.log('[Orchestrator] Step 2/5: Calling Executor (draft)...'); + state.step = 2; + state.draftArtifacts = await callAgent('executor', runId, 2, { + mode: 'draft', + plan: state.plan, + }) as unknown as ExecutorOutput; + + // Step 3: Reviewer (will BLOCK) + console.log('[Orchestrator] Step 3/5: Calling Reviewer (round 1)...'); + state.step = 3; + state.reviewResult = await callAgent('reviewer', runId, 3, { + artifacts: state.draftArtifacts, + reviewRound: 1, + }) as unknown as ReviewOutput; + + // Step 4: Executor (fix) + console.log('[Orchestrator] Step 4/5: Calling Executor (fix)...'); + state.step = 4; + state.fixArtifacts = await callAgent('executor', runId, 4, { + mode: 'fix', + fixes: state.reviewResult.fixes, + }) as unknown as ExecutorOutput; + + // Step 5: Reviewer (will APPROVE) + console.log('[Orchestrator] Step 5/5: Calling Reviewer (round 2)...'); + state.step = 5; + state.finalReview = await callAgent('reviewer', runId, 5, { + artifacts: state.fixArtifacts, + reviewRound: 2, + }) as unknown as ReviewOutput; + + scope.recordInputMessages([userRequest]); + scope.recordOutputMessages([JSON.stringify(state)]); + + finalResponse = formatFinalResponse(state); + console.log(`\n${'='.repeat(60)}`); + console.log(`[Orchestrator] Pipeline ${runId} complete!`); + console.log(`${'='.repeat(60)}\n`); + }); + } catch (error) { + scope.recordError(error as Error); + throw error; + } finally { + scope.dispose(); + } + + return finalResponse; +} + +function formatFinalResponse(state: PipelineState): string { + return [ + `**Sales Campaign Pipeline Complete** (Run: \`${state.runId}\`)`, + ``, + `**Step 1 — Plan:** Target "${state.plan?.targetSegment}" via ${state.plan?.channels?.join(', ')}`, + `**Step 2 — Draft:** Found ${state.draftArtifacts?.contacts?.length} contacts, created campaign \`${state.draftArtifacts?.campaign?.id}\``, + `**Step 3 — Review 1:** ${state.reviewResult?.status?.toUpperCase()} — ${state.reviewResult?.reason}`, + `**Step 4 — Fix:** Created ${state.fixArtifacts?.activities?.length} activities with GDPR-compliant opt-out`, + `**Step 5 — Review 2:** ${state.finalReview?.status?.toUpperCase()} — ${state.finalReview?.reason}`, + ].join('\n'); +} diff --git a/nodejs/openai/multi-agent-sample/src/planner/agent.ts b/nodejs/openai/multi-agent-sample/src/planner/agent.ts new file mode 100644 index 00000000..baeac74f --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/planner/agent.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { configDotenv } from 'dotenv'; +configDotenv(); + +import { TurnState, AgentApplication, TurnContext, MemoryStorage } from '@microsoft/agents-hosting'; +import { ActivityTypes } from '@microsoft/agents-activity'; +import { BaggageBuilder } from '@microsoft/agents-a365-observability'; +import { BaggageBuilderUtils } from '@microsoft/agents-a365-observability-hosting'; +import { handlePlannerRequest } from './handler'; + +export class PlannerAgent extends AgentApplication { + static authHandlerName = 'agentic'; + + constructor() { + super({ + startTypingTimer: true, + storage: new MemoryStorage(), + authorization: { + agentic: { + type: 'agentic', + }, + }, + }); + + this.onActivity(ActivityTypes.Message, async (context: TurnContext, _state: TurnState) => { + await this.handleMessage(context); + }, [PlannerAgent.authHandlerName]); + } + + private async handleMessage(turnContext: TurnContext): Promise { + const userMessage = turnContext.activity.text?.trim() || ''; + + if (!userMessage) { + await turnContext.sendActivity('Send me a campaign brief and I\'ll create a plan.'); + return; + } + + const baggageScope = BaggageBuilderUtils.fromTurnContext( + new BaggageBuilder(), + turnContext + ).sessionDescription('Planner agent') + .correlationId(`corr-${Date.now()}`) + .build(); + + try { + const result = await baggageScope.run(async () => { + return handlePlannerRequest( + { runId: `run-${Date.now()}`, step: 1, payload: { request: userMessage } }, + {} + ); + }); + await turnContext.sendActivity(JSON.stringify(result, null, 2)); + } catch (error) { + console.error('[Planner] Error:', error); + await turnContext.sendActivity(`Error: ${(error as Error).message}`); + } finally { + baggageScope.dispose(); + } + } +} + +export const agentApplication = new PlannerAgent(); diff --git a/nodejs/openai/multi-agent-sample/src/planner/handler.ts b/nodejs/openai/multi-agent-sample/src/planner/handler.ts new file mode 100644 index 00000000..8d193568 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/planner/handler.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InferenceScope, + InferenceDetails, + InferenceOperationType, + AgentDetails, + TenantDetails, + runWithExtractedTraceContext, +} from '@microsoft/agents-a365-observability'; +import { STUBBED_PLAN } from '../shared/stubbed-responses'; +import { AgentRequest, PlanOutput } from '../shared/types'; + +export async function handlePlannerRequest( + body: AgentRequest, + headers: Record +): Promise { + return runWithExtractedTraceContext(headers, async () => { + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: 'stubbed-planner', + }; + const agentDetails: AgentDetails = { + agentId: 'planner-agent', + agentName: 'Planner Agent', + conversationId: body.runId, + }; + const tenantDetails: TenantDetails = { tenantId: 'demo-tenant' }; + + const scope = InferenceScope.start(inferenceDetails, agentDetails, tenantDetails); + try { + return await scope.withActiveSpanAsync(async () => { + scope.recordAttributes({ + 'a365.agent.role': 'planner', + 'a365.step': body.step, + 'a365.run_id': body.runId, + 'a365.agent.call_id': `planner-${body.runId}`, + }); + + // Simulate LLM thinking time + await new Promise((r) => setTimeout(r, 200)); + + const plan = STUBBED_PLAN; + scope.recordInputMessages([JSON.stringify(body.payload)]); + scope.recordOutputMessages([JSON.stringify(plan)]); + scope.recordInputTokens(120); + scope.recordOutputTokens(85); + scope.recordFinishReasons(['stop']); + + console.log(`[Planner] Step ${body.step}: Generated campaign plan for "${plan.targetSegment}"`); + return plan; + }); + } finally { + scope.dispose(); + } + }); +} diff --git a/nodejs/openai/multi-agent-sample/src/planner/index.ts b/nodejs/openai/multi-agent-sample/src/planner/index.ts new file mode 100644 index 00000000..b2f12465 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/planner/index.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// IMPORTANT: Load environment variables FIRST before any other imports +import { configDotenv } from 'dotenv'; +configDotenv(); + +import { AuthConfiguration, authorizeJWT, CloudAdapter, loadAuthConfigFromEnv, Request } from '@microsoft/agents-hosting'; +import express, { Response } from 'express'; +import { agentApplication } from './agent'; +import { initializeObservability } from '../shared/observability'; +import { handlePlannerRequest } from './handler'; + +initializeObservability('Multi-Agent Planner'); + +// Only NODE_ENV=development explicitly disables authentication +const isDevelopment = process.env.NODE_ENV === 'development'; +const authConfig: AuthConfiguration = isDevelopment ? {} : loadAuthConfigFromEnv(); + +console.log(`[Planner] Environment: NODE_ENV=${process.env.NODE_ENV}, isDevelopment=${isDevelopment}`); + +const server = express(); +server.use(express.json()); + +// Health endpoint — placed BEFORE auth middleware so it doesn't require authentication +server.get('/api/health', (_req, res: Response) => { + res.status(200).json({ status: 'healthy', service: 'planner', timestamp: new Date().toISOString() }); +}); + +// Pipeline endpoint — placed BEFORE auth middleware (internal orchestrator calls) +server.post('/api/run', async (req: express.Request, res: Response) => { + try { + const result = await handlePlannerRequest(req.body, req.headers); + res.json({ runId: req.body.runId, step: req.body.step, result }); + } catch (error) { + console.error('[Planner] Error:', error); + res.status(500).json({ error: (error as Error).message }); + } +}); + +server.use(authorizeJWT(authConfig)); + +// Bot Framework endpoint — for Teams / Copilot Studio deployment +server.post('/api/messages', (req: Request, res: Response) => { + const adapter = agentApplication.adapter as CloudAdapter; + adapter.process(req, res, async (context) => { + await agentApplication.run(context); + }); +}); + +const port = Number(process.env.PLANNER_PORT) || 4001; +const host = process.env.AGENT_HOST ?? (isDevelopment ? 'localhost' : '0.0.0.0'); +server.listen(port, host, () => { + console.log(`[Planner] listening on ${host}:${port} for appId ${authConfig.clientId}`); +}).on('error', (err: unknown) => { + console.error(err); + process.exit(1); +}).on('close', () => { + console.log('[Planner] Server closed'); + process.exit(0); +}); diff --git a/nodejs/openai/multi-agent-sample/src/reviewer/agent.ts b/nodejs/openai/multi-agent-sample/src/reviewer/agent.ts new file mode 100644 index 00000000..9b9c2563 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/reviewer/agent.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { configDotenv } from 'dotenv'; +configDotenv(); + +import { TurnState, AgentApplication, TurnContext, MemoryStorage } from '@microsoft/agents-hosting'; +import { ActivityTypes } from '@microsoft/agents-activity'; +import { BaggageBuilder } from '@microsoft/agents-a365-observability'; +import { BaggageBuilderUtils } from '@microsoft/agents-a365-observability-hosting'; +import { handleReviewerRequest } from './handler'; + +export class ReviewerAgent extends AgentApplication { + static authHandlerName = 'agentic'; + + constructor() { + super({ + startTypingTimer: true, + storage: new MemoryStorage(), + authorization: { + agentic: { + type: 'agentic', + }, + }, + }); + + this.onActivity(ActivityTypes.Message, async (context: TurnContext, _state: TurnState) => { + await this.handleMessage(context); + }, [ReviewerAgent.authHandlerName]); + } + + private async handleMessage(turnContext: TurnContext): Promise { + const userMessage = turnContext.activity.text?.trim() || ''; + + if (!userMessage) { + await turnContext.sendActivity('Send me campaign artifacts and I\'ll review them for compliance.'); + return; + } + + const baggageScope = BaggageBuilderUtils.fromTurnContext( + new BaggageBuilder(), + turnContext + ).sessionDescription('Reviewer agent') + .correlationId(`corr-${Date.now()}`) + .build(); + + try { + const result = await baggageScope.run(async () => { + return handleReviewerRequest( + { runId: `run-${Date.now()}`, step: 3, payload: { request: userMessage, reviewRound: 1 } }, + {} + ); + }); + await turnContext.sendActivity(JSON.stringify(result, null, 2)); + } catch (error) { + console.error('[Reviewer] Error:', error); + await turnContext.sendActivity(`Error: ${(error as Error).message}`); + } finally { + baggageScope.dispose(); + } + } +} + +export const agentApplication = new ReviewerAgent(); diff --git a/nodejs/openai/multi-agent-sample/src/reviewer/handler.ts b/nodejs/openai/multi-agent-sample/src/reviewer/handler.ts new file mode 100644 index 00000000..4ca95aa5 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/reviewer/handler.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InferenceScope, + InferenceDetails, + InferenceOperationType, + AgentDetails, + TenantDetails, + runWithExtractedTraceContext, +} from '@microsoft/agents-a365-observability'; +import { ReviewOutput, AgentRequest } from '../shared/types'; +import { STUBBED_BLOCK_REVIEW, STUBBED_APPROVE_REVIEW } from '../shared/stubbed-responses'; + +export async function handleReviewerRequest( + body: AgentRequest, + headers: Record +): Promise { + return runWithExtractedTraceContext(headers, async () => { + const reviewRound = (body.payload as { reviewRound?: number }).reviewRound || 1; + const callId = `reviewer-round${reviewRound}-${body.runId}`; + + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: 'stubbed-reviewer', + }; + const agentDetails: AgentDetails = { + agentId: 'reviewer-agent', + agentName: 'Reviewer Agent', + conversationId: body.runId, + }; + const tenantDetails: TenantDetails = { tenantId: 'demo-tenant' }; + + const scope = InferenceScope.start(inferenceDetails, agentDetails, tenantDetails); + try { + return await scope.withActiveSpanAsync(async () => { + // Deterministic: round 1 blocks, round 2 approves + const review = reviewRound === 1 ? STUBBED_BLOCK_REVIEW : STUBBED_APPROVE_REVIEW; + + scope.recordAttributes({ + 'a365.agent.role': 'reviewer', + 'a365.step': body.step, + 'a365.run_id': body.runId, + 'a365.agent.call_id': callId, + 'a365.review.status': review.status, + 'a365.review.reason': review.reason, + }); + + // Simulate review thinking time + await new Promise((r) => setTimeout(r, 150)); + + scope.recordInputMessages([JSON.stringify(body.payload)]); + scope.recordOutputMessages([JSON.stringify(review)]); + scope.recordInputTokens(250); + scope.recordOutputTokens(90); + scope.recordFinishReasons(['stop']); + + console.log(`[Reviewer] Step ${body.step}: Round ${reviewRound} — ${review.status.toUpperCase()}`); + return review; + }); + } finally { + scope.dispose(); + } + }); +} diff --git a/nodejs/openai/multi-agent-sample/src/reviewer/index.ts b/nodejs/openai/multi-agent-sample/src/reviewer/index.ts new file mode 100644 index 00000000..b5c9d642 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/reviewer/index.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// IMPORTANT: Load environment variables FIRST before any other imports +import { configDotenv } from 'dotenv'; +configDotenv(); + +import { AuthConfiguration, authorizeJWT, CloudAdapter, loadAuthConfigFromEnv, Request } from '@microsoft/agents-hosting'; +import express, { Response } from 'express'; +import { agentApplication } from './agent'; +import { initializeObservability } from '../shared/observability'; +import { handleReviewerRequest } from './handler'; + +initializeObservability('Multi-Agent Reviewer'); + +// Only NODE_ENV=development explicitly disables authentication +const isDevelopment = process.env.NODE_ENV === 'development'; +const authConfig: AuthConfiguration = isDevelopment ? {} : loadAuthConfigFromEnv(); + +console.log(`[Reviewer] Environment: NODE_ENV=${process.env.NODE_ENV}, isDevelopment=${isDevelopment}`); + +const server = express(); +server.use(express.json()); + +// Health endpoint — placed BEFORE auth middleware so it doesn't require authentication +server.get('/api/health', (_req, res: Response) => { + res.status(200).json({ status: 'healthy', service: 'reviewer', timestamp: new Date().toISOString() }); +}); + +// Pipeline endpoint — placed BEFORE auth middleware (internal orchestrator calls) +server.post('/api/run', async (req: express.Request, res: Response) => { + try { + const result = await handleReviewerRequest(req.body, req.headers); + res.json({ runId: req.body.runId, step: req.body.step, result }); + } catch (error) { + console.error('[Reviewer] Error:', error); + res.status(500).json({ error: (error as Error).message }); + } +}); + +server.use(authorizeJWT(authConfig)); + +// Bot Framework endpoint — for Teams / Copilot Studio deployment +server.post('/api/messages', (req: Request, res: Response) => { + const adapter = agentApplication.adapter as CloudAdapter; + adapter.process(req, res, async (context) => { + await agentApplication.run(context); + }); +}); + +const port = Number(process.env.REVIEWER_PORT) || 4003; +const host = process.env.AGENT_HOST ?? (isDevelopment ? 'localhost' : '0.0.0.0'); +server.listen(port, host, () => { + console.log(`[Reviewer] listening on ${host}:${port} for appId ${authConfig.clientId}`); +}).on('error', (err: unknown) => { + console.error(err); + process.exit(1); +}).on('close', () => { + console.log('[Reviewer] Server closed'); + process.exit(0); +}); diff --git a/nodejs/openai/multi-agent-sample/src/shared/crm-tools.ts b/nodejs/openai/multi-agent-sample/src/shared/crm-tools.ts new file mode 100644 index 00000000..69c2fd7a --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/shared/crm-tools.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ExecuteToolScope, + ToolCallDetails, + AgentDetails, + TenantDetails, +} from '@microsoft/agents-a365-observability'; +import { CrmContact, CrmCampaign, CrmActivity } from './types'; +import { STUBBED_CONTACTS, STUBBED_CAMPAIGN, STUBBED_ACTIVITIES } from './stubbed-responses'; + +const EXECUTOR_AGENT: AgentDetails = { + agentId: 'executor-agent', + agentName: 'Executor Agent', +}; +const TENANT: TenantDetails = { tenantId: 'demo-tenant' }; + +async function executeWithToolScope( + toolName: string, + target: string, + runId: string, + agentCallId: string, + fn: () => Promise +): Promise { + const toolDetails: ToolCallDetails = { + toolName, + toolCallId: `${toolName}-${Date.now()}`, + description: `CRM operation: ${toolName}`, + toolType: 'mock-crm', + }; + + const scope = ExecuteToolScope.start(toolDetails, EXECUTOR_AGENT, TENANT, runId); + try { + return await scope.withActiveSpanAsync(async () => { + scope.recordAttributes({ + 'a365.tool.name': toolName, + 'a365.tool.target': target, + 'a365.tool.success': true, + 'a365.run_id': runId, + 'a365.agent.call_id': agentCallId, + }); + + const result = await fn(); + scope.recordResponse(JSON.stringify(result)); + return result; + }); + } catch (error) { + scope.recordAttributes({ 'a365.tool.success': false }); + scope.recordError(error as Error); + throw error; + } finally { + scope.dispose(); + } +} + +export async function searchContacts( + runId: string, + agentCallId: string, + _segment: string +): Promise { + return executeWithToolScope( + 'crm.searchContacts', + 'mock-crm', + runId, + agentCallId, + async () => { + await new Promise((r) => setTimeout(r, 100)); + return STUBBED_CONTACTS; + } + ); +} + +export async function createCampaign( + runId: string, + agentCallId: string, + _name: string, + contacts: CrmContact[] +): Promise { + return executeWithToolScope( + 'crm.createCampaign', + 'mock-crm', + runId, + agentCallId, + async () => { + await new Promise((r) => setTimeout(r, 150)); + return { ...STUBBED_CAMPAIGN, targetCount: contacts.length }; + } + ); +} + +export async function createActivities( + runId: string, + agentCallId: string, + _fixes: string[] +): Promise { + return executeWithToolScope( + 'crm.createActivities', + 'mock-crm', + runId, + agentCallId, + async () => { + await new Promise((r) => setTimeout(r, 120)); + return STUBBED_ACTIVITIES; + } + ); +} diff --git a/nodejs/openai/multi-agent-sample/src/shared/observability.ts b/nodejs/openai/multi-agent-sample/src/shared/observability.ts new file mode 100644 index 00000000..eb2c8925 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/shared/observability.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ObservabilityManager, + Builder, + Agent365ExporterOptions, +} from '@microsoft/agents-a365-observability'; +import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting'; + +/** + * Initializes A365 observability for a service process. + * Call once at service startup before any tracing operations. + */ +export function initializeObservability(serviceName: string): void { + const observability = ObservabilityManager.configure((builder: Builder) => { + const exporterOptions = new Agent365ExporterOptions(); + exporterOptions.maxQueueSize = 10; + + builder + .withService(serviceName, '1.0.0') + .withExporterOptions(exporterOptions) + .withTokenResolver((agentId: string, tenantId: string) => + AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId) + ); + }); + + observability.start(); +} diff --git a/nodejs/openai/multi-agent-sample/src/shared/stubbed-responses.ts b/nodejs/openai/multi-agent-sample/src/shared/stubbed-responses.ts new file mode 100644 index 00000000..415529c6 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/shared/stubbed-responses.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { PlanOutput, ExecutorOutput, ReviewOutput, CrmContact, CrmCampaign, CrmActivity } from './types'; + +export const STUBBED_PLAN: PlanOutput = { + targetSegment: 'Enterprise accounts in EMEA with >500 employees', + channels: ['email', 'linkedin', 'webinar'], + constraints: [ + 'Must include opt-out mechanism per GDPR', + 'Budget cap: $50,000', + 'Timeline: 4 weeks', + ], + timeline: '2026-03-10 to 2026-04-07', +}; + +function generateContacts(count: number): CrmContact[] { + const companies = [ + 'Contoso', 'Fabrikam', 'Northwind', 'Adatum', 'VanArsdel', + 'Trey Research', 'Litware', 'Proseware', 'Coho Winery', 'Lucerne Publishing', + ]; + const firstNames = [ + 'Alice', 'Bob', 'Carol', 'David', 'Eva', + 'Frank', 'Grace', 'Hank', 'Iris', 'Jack', + ]; + const contacts: CrmContact[] = []; + for (let i = 0; i < count; i++) { + const company = companies[i % companies.length]; + const name = firstNames[i % firstNames.length]; + contacts.push({ + id: `c-${String(i + 1).padStart(3, '0')}`, + name: `${name} ${company}`, + email: `${name.toLowerCase()}@${company.toLowerCase()}.com`, + segment: 'Enterprise-EMEA', + }); + } + return contacts; +} + +export const STUBBED_CONTACTS: CrmContact[] = generateContacts(50); + +export const STUBBED_CAMPAIGN: CrmCampaign = { + id: 'cmp-demo-001', + name: 'Q1 EMEA Enterprise Outreach', + status: 'draft', + targetCount: 50, +}; + +export const STUBBED_DRAFT_OUTPUT: ExecutorOutput = { + mode: 'draft', + contacts: STUBBED_CONTACTS, + campaign: STUBBED_CAMPAIGN, +}; + +/** First review always blocks — missing opt-out */ +export const STUBBED_BLOCK_REVIEW: ReviewOutput = { + status: 'blocked', + reason: 'Campaign email template is missing GDPR opt-out link. All outreach to EMEA contacts must include an unsubscribe mechanism.', + fixes: ['Add opt-out link to email template', 'Add unsubscribe landing page URL'], +}; + +function generateActivities(contacts: CrmContact[], touchesPerContact: number): CrmActivity[] { + const activities: CrmActivity[] = []; + let counter = 1; + for (const contact of contacts) { + for (let t = 1; t <= touchesPerContact; t++) { + activities.push({ + id: `act-${String(counter++).padStart(3, '0')}`, + type: 'email', + description: `Touch ${t}: Updated email with opt-out link for ${contact.name}`, + contactId: contact.id, + }); + } + } + return activities; +} + +export const STUBBED_ACTIVITIES: CrmActivity[] = generateActivities(STUBBED_CONTACTS, 3); + +export const STUBBED_FIX_OUTPUT: ExecutorOutput = { + mode: 'fix', + activities: STUBBED_ACTIVITIES, +}; + +/** Second review approves */ +export const STUBBED_APPROVE_REVIEW: ReviewOutput = { + status: 'approved', + reason: 'All GDPR compliance requirements met. Opt-out links verified. Campaign approved for launch.', +}; diff --git a/nodejs/openai/multi-agent-sample/src/shared/types.ts b/nodejs/openai/multi-agent-sample/src/shared/types.ts new file mode 100644 index 00000000..51f76e34 --- /dev/null +++ b/nodejs/openai/multi-agent-sample/src/shared/types.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** Full pipeline state passed between orchestrator steps */ +export interface PipelineState { + runId: string; + scenario: string; + teamId: string; + userRequest: string; + step: number; + plan?: PlanOutput; + draftArtifacts?: ExecutorOutput; + reviewResult?: ReviewOutput; + fixArtifacts?: ExecutorOutput; + finalReview?: ReviewOutput; +} + +export interface PlanOutput { + targetSegment: string; + channels: string[]; + constraints: string[]; + timeline: string; +} + +export interface ExecutorOutput { + mode: 'draft' | 'fix'; + contacts?: CrmContact[]; + campaign?: CrmCampaign; + activities?: CrmActivity[]; +} + +export interface CrmContact { + id: string; + name: string; + email: string; + segment: string; +} + +export interface CrmCampaign { + id: string; + name: string; + status: string; + targetCount: number; +} + +export interface CrmActivity { + id: string; + type: string; + description: string; + contactId: string; +} + +export interface ReviewOutput { + status: 'blocked' | 'approved'; + reason: string; + fixes?: string[]; +} + +/** Standard request body for inter-agent HTTP calls */ +export interface AgentRequest { + runId: string; + step: number; + payload: Record; +} + +/** Standard response body from sub-agent services */ +export interface AgentResponse { + runId: string; + step: number; + result: Record; +} diff --git a/nodejs/openai/multi-agent-sample/tsconfig.json b/nodejs/openai/multi-agent-sample/tsconfig.json new file mode 100644 index 00000000..5fb9619c --- /dev/null +++ b/nodejs/openai/multi-agent-sample/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "incremental": true, + "lib": ["ES2021"], + "target": "es2019", + "module": "commonjs", + "declaration": true, + "sourceMap": true, + "composite": true, + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + } +}