Skip to content

Commit feaad31

Browse files
authored
Take over session (#277)
*Issue #, if available:* Related with #269 *Description of changes:* Add `take_over` command for Slack. Usage: `@remote-swe take_over http://example.com/sessions/:sessionId` We now added take_over command By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent c4766a0 commit feaad31

File tree

14 files changed

+237
-32
lines changed

14 files changed

+237
-32
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This is an example implementation of a fully autonomous software development AI agent. The agent works in its own dedicated development environment, freeing you from being tied to your laptop!
44

5-
**TL;DR:** This is a self-hosted, fully open-source solution on AWS that offers a similar experience to Devin, OpenAI Codex, or Google Jules.
5+
**TL;DR:** This is a self-hosted, fully open-source solution on AWS that offers a similar experience to cloud-based asynchronous coding agents, such as Devin, OpenAI Codex, or Google Jules.
66

77
*日本語版のREADMEは[こちら](README_ja.md)をご覧ください。*
88

README_ja.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Remote SWE エージェントによるセッション例:
2727

2828
### Remote SWE Agentsによって作成されたプルリクエスト
2929

30-
エージェントによって作成された公開プルリクエストはすべて[こちら](https://github.com/search?q=is%3Apr+author%3Aremote-swe-user&type=pullrequests)で確認できます。GitHubユーザーからプッシュされたすべてのコミットは、エージェントによって自律的に作成されています。
30+
エージェントによって作成された公開プルリクエストはすべて[こちら](https://github.com/search?q=is%3Apr+author%3Aremote-swe-user&type=pullrequests)で確認できます。このGitHubユーザーからプッシュされたすべてのコミットは、エージェントによって自律的に作成されています。
3131

3232
## インストール手順
3333

packages/agent-core/src/lib/events.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ export const workerEventSchema = z.discriminatedUnion('type', [
5858
z.object({
5959
type: z.literal('forceStop'),
6060
}),
61+
z.object({
62+
type: z.literal('sessionUpdated'),
63+
}),
6164
]);
6265

6366
export async function sendWorkerEvent(workerId: string, event: z.infer<typeof workerEventSchema>) {

packages/agent-core/src/lib/sessions.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1-
import {
2-
GetCommand,
3-
PutCommand,
4-
QueryCommand,
5-
QueryCommandInput,
6-
UpdateCommand,
7-
paginateQuery,
8-
} from '@aws-sdk/lib-dynamodb';
1+
import { GetCommand, QueryCommand, QueryCommandInput, UpdateCommand, paginateQuery } from '@aws-sdk/lib-dynamodb';
92
import { ddb, TableName } from './aws';
103
import { AgentStatus, SessionItem } from '../schema';
4+
import { getConversationHistory } from './messages';
115

126
/**
137
* Get session information from DynamoDB
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The content of this directory must be able to imported from both Node.js and browser environment.

packages/slack-bolt-app/src/app.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { isAuthorized } from './util/auth';
44
import { handleDumpHistory } from './handlers/dump-history';
55
import { handleApproveUser } from './handlers/approve-user';
66
import { handleMessage } from './handlers/message';
7+
import { handleTakeOver } from './handlers/take-over';
78
import { IdempotencyAlreadyInProgressError } from '@aws-lambda-powertools/idempotency';
89
import { WebClient } from '@slack/web-api';
10+
import { NonRetryableError } from './util/error';
911

1012
const SigningSecret = process.env.SIGNING_SECRET!;
1113
const BotToken = process.env.BOT_TOKEN!;
@@ -26,7 +28,7 @@ const app = new App({
2628
let botId: string | undefined;
2729

2830
// Retrieve the bot ID on app startup
29-
(async () => {
31+
const getBotIdPromise = (async () => {
3032
try {
3133
const authInfo = await app.client.auth.test();
3234
botId = authInfo.user_id;
@@ -60,29 +62,46 @@ async function processMessage(
6062
try {
6163
// Include event type in idempotency key to prevent duplicate processing
6264
await makeIdempotent(async (_: string) => {
63-
const authorized = await isAuthorized(userId, channel);
64-
if (!authorized) {
65-
throw new Error('Unauthorized');
66-
}
67-
68-
if (message.toLowerCase().startsWith('approve_user')) {
69-
await handleApproveUser(event, client);
70-
} else if (message.toLowerCase().startsWith('dump_history')) {
71-
await handleDumpHistory(event, client);
72-
} else {
73-
await handleMessage(event, client);
65+
try {
66+
const authorized = await isAuthorized(userId, channel);
67+
if (!authorized) {
68+
throw new NonRetryableError('Unauthorized');
69+
}
70+
71+
if (message.toLowerCase().startsWith('approve_user')) {
72+
await handleApproveUser(event, client);
73+
} else if (message.toLowerCase().startsWith('dump_history')) {
74+
await handleDumpHistory(event, client);
75+
} else if (message.toLowerCase().startsWith('take_over')) {
76+
await handleTakeOver(event, client);
77+
} else {
78+
await handleMessage(event, client);
79+
}
80+
} catch (e: any) {
81+
console.log(e);
82+
if (e.message.includes('already_reacted')) return;
83+
if (e instanceof NonRetryableError) {
84+
await client.chat.postMessage({
85+
channel,
86+
text: `<@${userId}> Error: ${e.message}`,
87+
thread_ts: event.thread_ts ?? event.ts,
88+
});
89+
return;
90+
}
91+
throw e;
7492
}
7593
})(`${eventType}_${event.ts}`); // Use event type in key to avoid duplicates
7694
} catch (e: any) {
7795
console.log(e);
78-
if (e.message.includes('already_reacted')) return;
7996
if (e instanceof IdempotencyAlreadyInProgressError) return;
8097

8198
await client.chat.postMessage({
8299
channel,
83-
text: `<@${userId}> Error occurred ${e.message}`,
100+
text: `<@${userId}> Error: ${e.message}`,
84101
thread_ts: event.thread_ts ?? event.ts,
85102
});
103+
104+
throw e;
86105
}
87106
}
88107

@@ -92,6 +111,8 @@ app.event('app_mention', async ({ event, client }) => {
92111

93112
// Message event handler for processing messages without @mentions
94113
app.event('message', async ({ event, client }) => {
114+
await getBotIdPromise;
115+
95116
// Cast event to a type with properties we need
96117
const messageEvent = event as {
97118
text?: string;

packages/slack-bolt-app/src/handlers/approve-user.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { WebClient } from '@slack/web-api';
22
import { ApproveUsers } from '../util/auth';
3+
import { ValidationError } from '../util/error';
34

45
export async function handleApproveUser(
56
event: {
@@ -24,10 +25,10 @@ export async function handleApproveUser(
2425
.filter((elem: any) => elem.type == 'user')
2526
.map((elem: any) => elem.user_id);
2627
if (users.length >= 25) {
27-
throw new Error('too many users.');
28+
throw new ValidationError('too many users.');
2829
}
2930
if (users.length == 0) {
30-
throw new Error('no user is specified.');
31+
throw new ValidationError('no user is specified.');
3132
}
3233
await ApproveUsers(users, channel);
3334
await client.chat.postMessage({
@@ -38,5 +39,5 @@ export async function handleApproveUser(
3839
return;
3940
}
4041
}
41-
throw new Error('Usage: @remote-swe approve_user @user1 @user2');
42+
throw new ValidationError('Usage: @remote-swe approve_user @user1 @user2');
4243
}

packages/slack-bolt-app/src/handlers/dump-history.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { calculateCost } from '../util/cost';
44
import { Message } from '@aws-sdk/client-bedrock-runtime';
55
import * as fs from 'fs';
66
import * as os from 'os';
7+
import { getSessionIdFromSlack } from '../util/session-map';
78

89
export async function handleDumpHistory(
910
event: {
@@ -17,7 +18,7 @@ export async function handleDumpHistory(
1718
},
1819
client: WebClient
1920
): Promise<void> {
20-
const workerId = (event.thread_ts ?? event.ts).replace('.', '');
21+
const workerId = await getSessionIdFromSlack(event.channel, event.thread_ts ?? event.ts, false);
2122
const [history, tokenUsage] = await Promise.all([getConversationHistory(workerId), getTokenUsage(workerId)]);
2223

2324
const tempFile = os.tmpdir() + `/worker_${workerId}_history.txt`;

packages/slack-bolt-app/src/handlers/message.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AsyncHandlerEvent } from '../async-handler';
77
import { sendWorkerEvent } from '../../../agent-core/src/lib';
88
import { getWebappSessionUrl, sendWebappEvent } from '@remote-swe-agents/agent-core/lib';
99
import { saveSessionInfo } from '../util/session';
10+
import { getSessionIdFromSlack } from '../util/session-map';
1011

1112
const BotToken = process.env.BOT_TOKEN!;
1213
const lambda = new LambdaClient();
@@ -27,8 +28,9 @@ export async function handleMessage(
2728
const message = event.text.replace(/<@[A-Z0-9]+>\s*/g, '').trim();
2829
const userId = event.user ?? '';
2930
const channel = event.channel;
31+
const isThreadRoot = event.thread_ts == null;
3032

31-
const workerId = (event.thread_ts ?? event.ts).replace('.', '');
33+
const workerId = await getSessionIdFromSlack(channel, event.thread_ts ?? event.ts, isThreadRoot);
3234

3335
// Process image attachments if present
3436
const imageKeys = (
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { getConversationHistory, getSession, sendWorkerEvent } from '@remote-swe-agents/agent-core/lib';
2+
import { WebClient } from '@slack/web-api';
3+
import { SessionMap } from '../util/session-map';
4+
import { ddb, TableName } from '@remote-swe-agents/agent-core/aws';
5+
import { TransactWriteCommand, TransactWriteCommandInput } from '@aws-sdk/lib-dynamodb';
6+
import { ValidationError } from '../util/error';
7+
8+
export async function handleTakeOver(
9+
event: {
10+
text: string;
11+
user?: string;
12+
channel: string;
13+
ts: string;
14+
thread_ts?: string;
15+
blocks?: any[];
16+
files?: any[];
17+
},
18+
client: WebClient
19+
): Promise<void> {
20+
if (event.thread_ts) {
21+
throw new ValidationError('You can only take over a session from a new Slack thread.');
22+
}
23+
24+
const message = event.text
25+
.replace(/<@[A-Z0-9]+>\s*/g, '')
26+
.replace(/[<>]/g, '')
27+
.trim();
28+
29+
const match = message.match(/take_over\s+(https?:\/\/[^\s]+\/sessions\/([^\s\/]+))/);
30+
if (!match) {
31+
throw new ValidationError('Invalid format. Expected: take_over <session URL>');
32+
}
33+
34+
const sessionUrl = match[1];
35+
const sessionId = match[2];
36+
37+
console.log('Session URL:', sessionUrl);
38+
console.log('Session ID:', sessionId);
39+
40+
const session = await getSession(sessionId);
41+
if (!session) {
42+
throw new ValidationError(`No session was found for ${sessionId}`);
43+
}
44+
45+
if (session.slackChannelId && session.slackThreadTs) {
46+
throw new ValidationError(`This session already belongs to other Slack thread.`);
47+
}
48+
49+
await takeOverSessionToSlack(sessionId, event.channel, event.ts, event.user ?? '');
50+
await sendWorkerEvent(sessionId, { type: 'sessionUpdated' });
51+
52+
await client.chat.postMessage({
53+
channel: event.channel,
54+
thread_ts: event.ts,
55+
text: `<@${event.user}> Successfully took over the session ${sessionId}.`,
56+
});
57+
}
58+
59+
const takeOverSessionToSlack = async (
60+
workerId: string,
61+
slackChannelId: string,
62+
slackThreadTs: string,
63+
slackUserId: string
64+
) => {
65+
const { items } = await getConversationHistory(workerId);
66+
const lastUserMessage = items.findLast((i) => i.messageType == 'userMessage');
67+
68+
const transactItems: TransactWriteCommandInput['TransactItems'] = [
69+
{
70+
Put: {
71+
TableName,
72+
Item: {
73+
PK: 'session-map',
74+
SK: `slack-${slackChannelId}-${slackThreadTs}`,
75+
sessionId: workerId,
76+
} satisfies SessionMap,
77+
},
78+
},
79+
{
80+
Update: {
81+
TableName,
82+
Key: {
83+
PK: 'sessions',
84+
SK: workerId,
85+
},
86+
UpdateExpression: 'SET slackChannelId = :slackChannelId, slackThreadTs = :slackThreadTs',
87+
ExpressionAttributeValues: {
88+
':slackChannelId': slackChannelId,
89+
':slackThreadTs': slackThreadTs,
90+
},
91+
},
92+
},
93+
];
94+
95+
if (lastUserMessage) {
96+
transactItems.push({
97+
Update: {
98+
TableName,
99+
Key: {
100+
PK: lastUserMessage.PK,
101+
SK: lastUserMessage.SK,
102+
},
103+
UpdateExpression: 'SET slackUserId = :slackUserId',
104+
ExpressionAttributeValues: {
105+
':slackUserId': slackUserId,
106+
},
107+
},
108+
});
109+
}
110+
111+
await ddb.send(
112+
new TransactWriteCommand({
113+
TransactItems: transactItems,
114+
})
115+
);
116+
};

0 commit comments

Comments
 (0)