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
6 changes: 6 additions & 0 deletions packages/agent/src/posthog-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
ArtifactType,
PostHogAPIConfig,
StoredEntry,
Task,
TaskRun,
TaskRunArtifact,
} from "./types.js";
Expand Down Expand Up @@ -90,6 +91,11 @@ export class PostHogAPIClient {
return getLlmGatewayUrl(this.baseUrl);
}

async getTask(taskId: string): Promise<Task> {
const teamId = this.getTeamId();
return this.apiRequest<Task>(`/api/projects/${teamId}/tasks/${taskId}/`);
}

async getTaskRun(taskId: string, runId: string): Promise<TaskRun> {
const teamId = this.getTeamId();
return this.apiRequest<TaskRun>(
Expand Down
12 changes: 8 additions & 4 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ describe("AgentServer HTTP Mode", () => {
apiUrl: "http://localhost:8000",
apiKey: "test-api-key",
projectId: 1,
mode: "interactive",
taskId: "test-task-id",
runId: "test-run-id",
});
return server;
};
Expand All @@ -108,21 +111,22 @@ describe("AgentServer HTTP Mode", () => {
team_id: 1,
user_id: 1,
distinct_id: "test-distinct-id",
mode: "interactive",
...overrides,
},
TEST_PRIVATE_KEY,
);
};

describe("GET /health", () => {
it("returns ok status", async () => {
it("returns ok status with active session", async () => {
await createServer().start();

const response = await fetch(`http://localhost:${port}/health`);
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok", hasSession: false });
expect(body).toEqual({ status: "ok", hasSession: true });
});
});

Expand Down Expand Up @@ -179,9 +183,9 @@ describe("AgentServer HTTP Mode", () => {
expect(response.status).toBe(401);
});

it("returns 400 when no session exists", async () => {
it("returns 400 when run_id does not match active session", async () => {
await createServer().start();
const token = createToken();
const token = createToken({ run_id: "different-run-id" });

const response = await fetch(`http://localhost:${port}/command`, {
method: "POST",
Expand Down
115 changes: 111 additions & 4 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { PostHogAPIClient } from "../posthog-api.js";
import { SessionLogWriter } from "../session-log-writer.js";
import { TreeTracker } from "../tree-tracker.js";
import type { DeviceInfo, TreeSnapshotEvent } from "../types.js";
import type { AgentMode, DeviceInfo, TreeSnapshotEvent } from "../types.js";
import { AsyncMutex } from "../utils/async-mutex.js";
import { getLlmGatewayUrl } from "../utils/gateway.js";
import { Logger } from "../utils/logger.js";
Expand Down Expand Up @@ -144,13 +144,23 @@ export class AgentServer {
private server: ServerType | null = null;
private session: ActiveSession | null = null;
private app: Hono;
private posthogAPI: PostHogAPIClient;

constructor(config: AgentServerConfig) {
this.config = config;
this.logger = new Logger({ debug: true, prefix: "[AgentServer]" });
this.posthogAPI = new PostHogAPIClient({
apiUrl: config.apiUrl,
projectId: config.projectId,
getApiKey: () => config.apiKey,
});
this.app = this.createApp();
}

private getEffectiveMode(payload: JwtPayload): AgentMode {
return payload.mode ?? this.config.mode;
}

private createApp(): Hono {
const app = new Hono();

Expand Down Expand Up @@ -309,7 +319,7 @@ export class AgentServer {
}

async start(): Promise<void> {
return new Promise((resolve) => {
await new Promise<void>((resolve) => {
this.server = serve(
{
fetch: this.app.fetch,
Expand All @@ -321,6 +331,26 @@ export class AgentServer {
},
);
});

await this.autoInitializeSession();
}

private async autoInitializeSession(): Promise<void> {
const { taskId, runId, mode, projectId } = this.config;

this.logger.info("Auto-initializing session", { taskId, runId, mode });

// Create a synthetic payload from config (no JWT needed for auto-init)
const payload: JwtPayload = {
task_id: taskId,
run_id: runId,
team_id: projectId,
user_id: 0, // System-initiated
distinct_id: "agent-server",
mode,
};

await this.initializeSession(payload, null);
}

async stop(): Promise<void> {
Expand Down Expand Up @@ -409,7 +439,7 @@ export class AgentServer {

private async initializeSession(
payload: JwtPayload,
sseController: SseController,
sseController: SseController | null,
): Promise<void> {
if (this.session) {
await this.cleanupSession();
Expand Down Expand Up @@ -501,6 +531,73 @@ export class AgentServer {
};

this.logger.info("Session initialized successfully");

await this.sendInitialTaskMessage(payload);
}

private async sendInitialTaskMessage(payload: JwtPayload): Promise<void> {
if (!this.session) return;

try {
this.logger.info("Fetching task details", { taskId: payload.task_id });
const task = await this.posthogAPI.getTask(payload.task_id);

if (!task.description) {
this.logger.warn("Task has no description, skipping initial message");
return;
}

this.logger.info("Sending initial task message", {
taskId: payload.task_id,
descriptionLength: task.description.length,
});

const result = await this.session.clientConnection.prompt({
sessionId: payload.run_id,
prompt: [{ type: "text", text: task.description }],
});

this.logger.info("Initial task message completed", {
stopReason: result.stopReason,
});

// Only auto-complete for background mode
const mode = this.getEffectiveMode(payload);
if (mode === "background") {
await this.signalTaskComplete(payload, result.stopReason);
} else {
this.logger.info("Interactive mode - staying open for conversation");
}
} catch (error) {
this.logger.error("Failed to send initial task message", error);
// Signal failure for background mode
const mode = this.getEffectiveMode(payload);
if (mode === "background") {
await this.signalTaskComplete(payload, "error");
}
}
}

private async signalTaskComplete(
payload: JwtPayload,
stopReason: string,
): Promise<void> {
const status =
stopReason === "cancelled"
? "cancelled"
: stopReason === "error"
? "failed"
: "completed";

try {
await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
status,
error_message: stopReason === "error" ? "Agent error" : undefined,
});
this.logger.info("Task completion signaled", { status, stopReason });
} catch (error) {
this.logger.error("Failed to signal task completion", error);
}
}

private configureEnvironment(): void {
Expand Down Expand Up @@ -529,11 +626,21 @@ export class AgentServer {
});
}

private createCloudClient(_payload: JwtPayload) {
private createCloudClient(payload: JwtPayload) {
const mode = this.getEffectiveMode(payload);

return {
requestPermission: async (params: {
options: Array<{ kind: string; optionId: string }>;
}) => {
// Background mode: always auto-approve permissions
// Interactive mode: also auto-approve for now (user can monitor via SSE)
// Future: interactive mode could pause and wait for user approval via SSE
this.logger.debug("Permission request", {
mode,
options: params.options,
});

const allowOption = params.options.find(
(o) => o.kind === "allow_once" || o.kind === "allow_always",
);
Expand Down
12 changes: 12 additions & 0 deletions packages/agent/src/server/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ program
.name("agent-server")
.description("PostHog cloud agent server - runs in sandbox environments")
.option("--port <port>", "HTTP server port", "3001")
.option(
"--mode <mode>",
"Execution mode: interactive or background",
"interactive",
)
.requiredOption("--repositoryPath <path>", "Path to the repository")
.requiredOption("--taskId <id>", "Task ID")
.requiredOption("--runId <id>", "Task run ID")
.action(async (options) => {
const envResult = envSchema.safeParse(process.env);

Expand All @@ -51,13 +58,18 @@ program

const env = envResult.data;

const mode = options.mode === "background" ? "background" : "interactive";

const server = new AgentServer({
port: parseInt(options.port, 10),
jwtPublicKey: env.JWT_PUBLIC_KEY,
repositoryPath: options.repositoryPath,
apiUrl: env.POSTHOG_API_URL,
apiKey: env.POSTHOG_PERSONAL_API_KEY,
projectId: env.POSTHOG_PROJECT_ID,
mode,
taskId: options.taskId,
runId: options.runId,
});

process.on("SIGINT", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/server/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const userDataSchema = z.object({
team_id: z.number(),
user_id: z.number(),
distinct_id: z.string(),
mode: z.enum(["interactive", "background"]).optional().default("interactive"),
});

const jwtPayloadSchema = userDataSchema.extend({
Expand Down
5 changes: 5 additions & 0 deletions packages/agent/src/server/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type { AgentMode } from "../types.js";

export interface AgentServerConfig {
port: number;
repositoryPath: string;
apiUrl: string;
apiKey: string;
projectId: number;
jwtPublicKey: string; // RS256 public key for JWT verification
mode: AgentMode;
taskId: string;
runId: string;
}
3 changes: 3 additions & 0 deletions packages/agent/src/test/fixtures/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export function createAgentServerConfig(
apiKey: "test-api-key",
projectId: 1,
jwtPublicKey: TEST_PUBLIC_KEY,
mode: "interactive",
taskId: "test-task-id",
runId: "test-run-id",
...overrides,
};
}
Loading