diff --git a/nodejs/devin/sample-agent/.env.example b/nodejs/devin/sample-agent/.env.example new file mode 100644 index 00000000..4f7a1993 --- /dev/null +++ b/nodejs/devin/sample-agent/.env.example @@ -0,0 +1,7 @@ +# Devin API Configuration +DEVIN_BASE_URL=https://api.devin.ai/v1 +DEVIN_API_KEY=your_devin_api_key_here + +# Polling interval in seconds (how often to check for Devin responses) +POLLING_INTERVAL_SECONDS=10 + diff --git a/nodejs/devin/sample-agent/README.md b/nodejs/devin/sample-agent/README.md new file mode 100644 index 00000000..1ea1912b --- /dev/null +++ b/nodejs/devin/sample-agent/README.md @@ -0,0 +1 @@ +TODO diff --git a/nodejs/devin/sample-agent/package.json b/nodejs/devin/sample-agent/package.json new file mode 100644 index 00000000..8b84888e --- /dev/null +++ b/nodejs/devin/sample-agent/package.json @@ -0,0 +1,30 @@ +{ + "name": "devin-agent-sample", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "build": "tsc", + "start": "node --env-file=.env dist/index.js", + "test-tool": "agentsplayground", + "install:clean": "npm run clean && npm install", + "clean": "rimraf dist node_modules package-lock.json" + }, + "keywords": [], + "license": "ISC", + "description": "", + "dependencies": { + "@microsoft/agents-a365-notifications": "*", + "@microsoft/agents-a365-observability": "*", + "@microsoft/agents-a365-runtime": "*", + "@microsoft/agents-a365-tooling": "*", + "@microsoft/agents-hosting": "^1.0.15", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.20", + "nodemon": "^3.1.10", + "rimraf": "^5.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.2" + } +} \ No newline at end of file diff --git a/nodejs/devin/sample-agent/src/agent.ts b/nodejs/devin/sample-agent/src/agent.ts new file mode 100644 index 00000000..f9b73ac1 --- /dev/null +++ b/nodejs/devin/sample-agent/src/agent.ts @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + AgentDetails, + BaggageBuilder, + InferenceDetails, + InferenceOperationType, + InferenceScope, + InvokeAgentScope, + TenantDetails, +} from "@microsoft/agents-a365-observability"; +import { Activity, ActivityTypes } from "@microsoft/agents-activity"; +import { + AgentApplication, + DefaultConversationState, + TurnContext, + TurnState, +} from "@microsoft/agents-hosting"; +import { Stream } from "stream"; +import { v4 as uuidv4 } from "uuid"; +import { devinClient } from "./devin-client"; +import { getAgentDetails, getTenantDetails } from "./utils"; + +interface ConversationState extends DefaultConversationState { + count: number; +} +type ApplicationTurnState = TurnState; + +export class A365Agent extends AgentApplication { + isApplicationInstalled: boolean = false; + agentName = "Devin Agent"; + + constructor() { + super(); + + this.onActivity( + ActivityTypes.Message, + async (context: TurnContext, state: ApplicationTurnState) => { + // Increment count state + let count = state.conversation.count ?? 0; + state.conversation.count = ++count; + + // Extract agent and tenant details from context + const invokeAgentDetails = getAgentDetails(context); + const tenantDetails = getTenantDetails(context); + + // Create BaggageBuilder scope + const baggageScope = new BaggageBuilder() + .tenantId(tenantDetails.tenantId) + .agentId(invokeAgentDetails.agentId) + .correlationId(uuidv4()) + .agentName(invokeAgentDetails.agentName) + .conversationId(context.activity.conversation?.id) + .build(); + + await baggageScope.run(async () => { + const invokeAgentScope = InvokeAgentScope.start( + invokeAgentDetails, + tenantDetails + ); + + await invokeAgentScope.withActiveSpanAsync(async () => { + invokeAgentScope.recordInputMessages([ + context.activity.text ?? "Unknown text", + ]); + + await context.sendActivity(Activity.fromObject({ type: "typing" })); + await this.handleAgentMessageActivity( + context, + invokeAgentScope, + invokeAgentDetails, + tenantDetails + ); + }); + + invokeAgentScope.dispose(); + }); + + baggageScope.dispose(); + } + ); + + this.onActivity( + ActivityTypes.InstallationUpdate, + async (context: TurnContext, state: TurnState) => { + await this.handleInstallationUpdateActivity(context, state); + } + ); + } + + /** + * Handles incoming user messages and sends responses. + */ + async handleAgentMessageActivity( + turnContext: TurnContext, + invokeAgentScope: InvokeAgentScope, + agentDetails: AgentDetails, + tenantDetails: TenantDetails + ): Promise { + if (!this.isApplicationInstalled) { + await turnContext.sendActivity( + "Please install the application before sending messages." + ); + return; + } + + const userMessage = turnContext.activity.text?.trim() || ""; + + if (!userMessage) { + await turnContext.sendActivity( + "Please send me a message and I'll help you!" + ); + return; + } + + try { + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "claude-3-7-sonnet-20250219", + providerName: "cognition-ai", + inputTokens: Math.ceil(userMessage.length / 4), // Rough estimate + responseId: `resp-${Date.now()}`, + outputTokens: 0, // Will be updated after response + finishReasons: undefined, + }; + + const inferenceScope = InferenceScope.start( + inferenceDetails, + agentDetails, + tenantDetails + ); + + let totalResponseLength = 0; + const responseStream = new Stream() + .on("data", async (chunk) => { + totalResponseLength += (chunk as string).length; + invokeAgentScope.recordOutputMessages([`LLM Response: ${chunk}`]); + inferenceScope.recordOutputMessages([`LLM Response: ${chunk}`]); + await turnContext.sendActivity(chunk); + }) + .on("error", async (error) => { + invokeAgentScope.recordOutputMessages([`Streaming error: ${error}`]); + inferenceScope.recordOutputMessages([`Streaming error: ${error}`]); + await turnContext.sendActivity(error); + }) + .on("close", () => { + inferenceScope.recordOutputTokens(Math.ceil(totalResponseLength / 4)); // Rough estimate + inferenceScope.recordFinishReasons(["stop"]); + }); + + inferenceScope.recordInputMessages([userMessage]); + + await devinClient.invokeAgent(userMessage, responseStream); + } catch (error) { + invokeAgentScope.recordOutputMessages([`LLM error: ${error}`]); + await turnContext.sendActivity( + "There was an error processing your request" + ); + } + } + + /** + * Handles agent installation and removal events. + */ + async handleInstallationUpdateActivity( + turnContext: TurnContext, + state: TurnState + ): Promise { + if (turnContext.activity.action === "add") { + this.isApplicationInstalled = true; + await turnContext.sendActivity( + "Thank you for hiring me! Looking forward to assisting you in your professional journey!" + ); + } else if (turnContext.activity.action === "remove") { + this.isApplicationInstalled = false; + await turnContext.sendActivity( + "Thank you for your time, I enjoyed working with you." + ); + } + } +} + +export const agentApplication = new A365Agent(); diff --git a/nodejs/devin/sample-agent/src/devin-client.ts b/nodejs/devin/sample-agent/src/devin-client.ts new file mode 100644 index 00000000..563332a9 --- /dev/null +++ b/nodejs/devin/sample-agent/src/devin-client.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import Stream from "stream"; +import { + DevinCreateSessionResponse, + DevinSessionResponse, + DevinSessionStatus, +} from "./types/devin-client.types"; + +export interface Client { + invokeAgent(prompt: string, responseStream: Stream): Promise; +} + +/** + * DevinClient provides an interface to interact with the Devin API + * It maintains agentOptions as an instance field and exposes an invokeAgent method. + */ +export class DevinClient implements Client { + private readonly devinMessageType = "devin_message"; + private readonly devinBaseUrl: string; + private readonly devinApiKey: string; + private readonly pollingIntervalSeconds: number; + private currentSession: string | undefined; + + constructor() { + this.devinBaseUrl = process.env.DEVIN_BASE_URL || ""; + this.devinApiKey = process.env.DEVIN_API_KEY || ""; + this.pollingIntervalSeconds = parseInt( + process.env.POLLING_INTERVAL_SECONDS || "10" + ); + + if (!this.devinBaseUrl) { + throw new Error("DEVIN_BASE_URL environment variable is required"); + } + + if (!this.devinApiKey) { + throw new Error("DEVIN_API_KEY environment variable is required"); + } + } + + /** + * Sends a user message to Devin API and returns the AI's response in a stream. + * Handles streaming results and error reporting. + * + * @param {string} prompt - The message or prompt to send to Devin. + * @param {Stream} responseStream - A stream for the client to send Devin's replies to. + * @returns {Promise} + */ + async invokeAgent(prompt: string, responseStream: Stream): Promise { + const pollMs = this.pollingIntervalSeconds * 1_000 || 10_000; + + this.currentSession = await this.promptDevin(prompt, this.currentSession); + await this.getDevinResponse(this.currentSession, pollMs, responseStream); + } + + private async promptDevin( + prompt: string, + sessionId?: string + ): Promise { + const requestUrl = sessionId + ? `${this.devinBaseUrl}/sessions/${sessionId}/message` + : `${this.devinBaseUrl}/sessions`; + + const requestBody = sessionId ? { message: prompt } : { prompt }; + + const response = await fetch(requestUrl, { + method: "POST", + headers: this.getReqHeaders(), + body: JSON.stringify(requestBody), + }); + + const data = (await response.json()) as DevinCreateSessionResponse; + const rawSessionId = String(data?.session_id ?? ""); + return sessionId || rawSessionId.replace("devin-", ""); + } + + private async getDevinResponse( + sessionId: string, + pollMs: number, + responseStream: Stream, + timeoutMs: number = 300_000 + ): Promise { + const deadline = Date.now() + timeoutMs; + const sentMessages = new Set(); + let latestStatus = DevinSessionStatus.new; + + console.debug("starting poll for Devin's reply"); + + while (Object.values(DevinSessionStatus).includes(latestStatus)) { + console.debug("calling GET session/messages"); + if (Date.now() > deadline) { + console.info("Timed out, not polling for an answer anymore"); + break; + } + + await this.delay(pollMs); + const requestUrl = `${this.devinBaseUrl}/sessions/${sessionId}`; + + const response = await fetch(requestUrl, { + headers: this.getReqHeaders(), + }); + + if (response.status !== 200) { + console.error(`API call failed with status ${response.status}}`); + console.error(`Error response: ${JSON.stringify(response)}`); + responseStream.emit( + "data", + "There was an error processing your request, please try again" + ); + break; + } + + const data = (await response.json()) as DevinSessionResponse; + latestStatus = data.status; + console.debug(`Current Devin Session status is: ${latestStatus}`); + const latestMessage = data?.messages?.pop(); + console.debug(`latest message is ${JSON.stringify(latestMessage)}`); + + if (latestMessage && latestMessage.type === this.devinMessageType) { + if (!sentMessages.has(latestMessage.event_id)) { + const messageContent = String(latestMessage?.message); + responseStream.emit("data", messageContent); + sentMessages.add(latestMessage.event_id); + console.debug(`emit data event with content: ${messageContent}}`); + } + } + } + + console.debug("emitting close event"); + responseStream.emit("close"); + } + + private delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); + } + + private getReqHeaders(): Record { + return { + Authorization: `Bearer ${this.devinApiKey}`, + "Content-Type": "application/json", + }; + } +} + +export const devinClient = new DevinClient(); diff --git a/nodejs/devin/sample-agent/src/index.ts b/nodejs/devin/sample-agent/src/index.ts new file mode 100644 index 00000000..a6c58093 --- /dev/null +++ b/nodejs/devin/sample-agent/src/index.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + AuthConfiguration, + authorizeJWT, + CloudAdapter, + loadAuthConfigFromEnv, + Request, +} from "@microsoft/agents-hosting"; +import express, { Response } from "express"; +import { agentApplication } from "./agent"; + +const authConfig: AuthConfiguration = loadAuthConfigFromEnv(); +const adapter = new CloudAdapter(authConfig); + +const app = express(); +app.use(express.json()); +app.use(authorizeJWT(authConfig)); + +app.post("/api/messages", async (req: Request, res: Response) => { + await adapter.process(req, res, async (context) => { + const app = agentApplication; + await app.run(context); + }); +}); + +const port = process.env.PORT || 3978; +const server = app + .listen(port, () => { + console.log( + `\nServer listening to port ${port} for appId ${authConfig.clientId} debug ${process.env.DEBUG}` + ); + }) + .on("error", async (err) => { + console.error(err); + process.exit(1); + }) + .on("close", async () => { + console.log("Server is shutting down..."); + }); + +process.on("SIGINT", () => { + console.log("Received SIGINT. Shutting down gracefully..."); + server.close(() => { + console.log("Server closed."); + process.exit(0); + }); +}); diff --git a/nodejs/devin/sample-agent/src/types/devin-client.types.ts b/nodejs/devin/sample-agent/src/types/devin-client.types.ts new file mode 100644 index 00000000..e5c3135c --- /dev/null +++ b/nodejs/devin/sample-agent/src/types/devin-client.types.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export interface DevinCreateSessionResponse { + session_id?: string; +} + +export interface DevinSessionResponse { + status: DevinSessionStatus; + messages?: { type: string; message?: string; event_id: string }[]; +} + +export enum DevinSessionStatus { + new = "new", + claimed = "claimed", + running = "running", +} diff --git a/nodejs/devin/sample-agent/src/utils.ts b/nodejs/devin/sample-agent/src/utils.ts new file mode 100644 index 00000000..1ba74519 --- /dev/null +++ b/nodejs/devin/sample-agent/src/utils.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ExecutionType, + InvokeAgentDetails, + TenantDetails, +} from "@microsoft/agents-a365-observability"; +import { TurnContext } from "@microsoft/agents-hosting"; + +// Helper functions to extract agent and tenant details from context +export function getAgentDetails(context: TurnContext): InvokeAgentDetails { + // Extract agent ID from activity recipient - use agenticAppId (camelCase, not underscore) + const agentId = + (context.activity.recipient as any)?.agenticAppId || + process.env.AGENT_ID || + "devin-agent"; + + console.log( + `🎯 Agent ID: ${agentId} (from ${ + (context.activity.recipient as any)?.agenticAppId + ? "activity.recipient.agenticAppId" + : "environment/fallback" + })` + ); + + return { + agentId: agentId, + agentName: + (context.activity.recipient as any)?.name || + process.env.AGENT_NAME || + "Devin Agent Sample", + conversationId: context.activity.conversation?.id, + request: { + content: context.activity.text || "Unknown text", + executionType: ExecutionType.HumanToAgent, + sessionId: context.activity.conversation?.id, + }, + }; +} + +export function getTenantDetails(context: TurnContext): TenantDetails { + // First try to extract tenant ID from activity recipient - use tenantId (camelCase) + const tenantId = + (context.activity.recipient as any)?.tenantId || + process.env.connections__serviceConnection__settings__tenantId || + "sample-tenant"; + + console.log( + `🏢 Tenant ID: ${tenantId} (from ${ + (context.activity.recipient as any)?.tenantId + ? "activity.recipient.tenantId" + : "environment/fallback" + })` + ); + + return { tenantId: tenantId }; +} diff --git a/nodejs/devin/sample-agent/tsconfig.json b/nodejs/devin/sample-agent/tsconfig.json new file mode 100644 index 00000000..0e188450 --- /dev/null +++ b/nodejs/devin/sample-agent/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" + } +} \ No newline at end of file