Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions nodejs/devin/sample-agent/.env.example
Original file line number Diff line number Diff line change
@@ -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

Comment thread
walterluna marked this conversation as resolved.
1 change: 1 addition & 0 deletions nodejs/devin/sample-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TODO
Comment thread
walterluna marked this conversation as resolved.
30 changes: 30 additions & 0 deletions nodejs/devin/sample-agent/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
184 changes: 184 additions & 0 deletions nodejs/devin/sample-agent/src/agent.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
walterluna marked this conversation as resolved.
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<ConversationState>;

export class A365Agent extends AgentApplication<ApplicationTurnState> {
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<void> {
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<void> {
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();
146 changes: 146 additions & 0 deletions nodejs/devin/sample-agent/src/devin-client.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
walterluna marked this conversation as resolved.

export interface Client {
invokeAgent(prompt: string, responseStream: Stream): Promise<void>;
}

/**
* 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<void>}
*/
async invokeAgent(prompt: string, responseStream: Stream): Promise<void> {
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<string> {
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<void> {
const deadline = Date.now() + timeoutMs;
const sentMessages = new Set<string>();
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<void> {
return new Promise((r) => setTimeout(r, ms));
}

private getReqHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.devinApiKey}`,
"Content-Type": "application/json",
};
}
}

export const devinClient = new DevinClient();
Loading