@posthog/agent 2.3.1 → 2.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.1",
3
+ "version": "2.3.10",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -26,6 +26,9 @@ export const POSTHOG_NOTIFICATIONS = {
26
26
  /** Task has completed (success or failure) */
27
27
  TASK_COMPLETE: "_posthog/task_complete",
28
28
 
29
+ /** Agent finished processing a turn (prompt returned, waiting for next input) */
30
+ TURN_COMPLETE: "_posthog/turn_complete",
31
+
29
32
  /** Error occurred during task execution */
30
33
  ERROR: "_posthog/error",
31
34
 
@@ -22,11 +22,12 @@ import type {
22
22
  BetaWebSearchToolResultBlockParam,
23
23
  } from "@anthropic-ai/sdk/resources/beta.mjs";
24
24
 
25
- const SYSTEM_REMINDER = `
25
+ const SYSTEM_REMINDER_REGEX =
26
+ /\s*<system-reminder>[\s\S]*?<\/system-reminder>/g;
26
27
 
27
- <system-reminder>
28
- Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
29
- </system-reminder>`;
28
+ function stripSystemReminders(value: string): string {
29
+ return value.replace(SYSTEM_REMINDER_REGEX, "");
30
+ }
30
31
 
31
32
  import { resourceLink, text, toolContent } from "../../../utils/acp-content.js";
32
33
  import { getMcpToolMetadata } from "../mcp/tool-metadata.js";
@@ -537,9 +538,7 @@ export function toolUpdateFromToolResult(
537
538
  return {
538
539
  type: "content" as const,
539
540
  content: text(
540
- markdownEscape(
541
- (itemObj.text ?? "").replace(SYSTEM_REMINDER, ""),
542
- ),
541
+ markdownEscape(stripSystemReminders(itemObj.text ?? "")),
543
542
  ),
544
543
  };
545
544
  }
@@ -565,9 +564,7 @@ export function toolUpdateFromToolResult(
565
564
  ) {
566
565
  return {
567
566
  content: toolContent()
568
- .text(
569
- markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
570
- )
567
+ .text(markdownEscape(stripSystemReminders(toolResult.content)))
571
568
  .build(),
572
569
  };
573
570
  }
@@ -699,7 +696,7 @@ function itemToText(item: unknown): string | null {
699
696
  const obj = item as Record<string, unknown>;
700
697
  // Standard text block
701
698
  if (obj.type === "text" && typeof obj.text === "string") {
702
- return obj.text;
699
+ return stripSystemReminders(obj.text);
703
700
  }
704
701
  // Any other structured object — serialize it
705
702
  try {
package/src/resume.ts CHANGED
@@ -47,7 +47,7 @@ export interface ToolCallInfo {
47
47
  export interface ResumeConfig {
48
48
  taskId: string;
49
49
  runId: string;
50
- repositoryPath: string;
50
+ repositoryPath?: string;
51
51
  apiClient: PostHogAPIClient;
52
52
  logger?: Logger;
53
53
  }
@@ -26,7 +26,7 @@ export interface ToolCallInfo {
26
26
  export interface ResumeInput {
27
27
  taskId: string;
28
28
  runId: string;
29
- repositoryPath: string;
29
+ repositoryPath?: string;
30
30
  apiClient: PostHogAPIClient;
31
31
  logger?: Logger;
32
32
  }
@@ -78,7 +78,7 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
78
78
  // Step 4: Apply snapshot if present (wrapped in step for consistent logging)
79
79
  // Note: We use a try/catch inside the step because snapshot failure should NOT fail the saga
80
80
  let snapshotApplied = false;
81
- if (latestSnapshot?.archiveUrl) {
81
+ if (latestSnapshot?.archiveUrl && repositoryPath) {
82
82
  this.log.info("Found tree snapshot", {
83
83
  treeHash: latestSnapshot.treeHash,
84
84
  hasArchiveUrl: true,
@@ -120,6 +120,14 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
120
120
  // Inner ApplySnapshotSaga handles its own rollback
121
121
  },
122
122
  });
123
+ } else if (latestSnapshot?.archiveUrl && !repositoryPath) {
124
+ this.log.warn(
125
+ "Snapshot found but no repositoryPath configured - files cannot be restored",
126
+ {
127
+ treeHash: latestSnapshot.treeHash,
128
+ changes: latestSnapshot.changes?.length ?? 0,
129
+ },
130
+ );
123
131
  } else if (latestSnapshot) {
124
132
  this.log.warn(
125
133
  "Snapshot found but has no archive URL - files cannot be restored",
@@ -11,7 +11,13 @@ import {
11
11
  createAcpConnection,
12
12
  type InProcessAcpConnection,
13
13
  } from "../adapters/acp-connection.js";
14
+ import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration.js";
14
15
  import { PostHogAPIClient } from "../posthog-api.js";
16
+ import {
17
+ type ConversationTurn,
18
+ type ResumeState,
19
+ resumeFromLog,
20
+ } from "../resume.js";
15
21
  import { SessionLogWriter } from "../session-log-writer.js";
16
22
  import { TreeTracker } from "../tree-tracker.js";
17
23
  import type {
@@ -155,6 +161,7 @@ export class AgentServer {
155
161
  private posthogAPI: PostHogAPIClient;
156
162
  private questionRelayedToSlack = false;
157
163
  private detectedPrUrl: string | null = null;
164
+ private resumeState: ResumeState | null = null;
158
165
 
159
166
  private emitConsoleLog = (
160
167
  level: LogLevel,
@@ -380,6 +387,34 @@ export class AgentServer {
380
387
 
381
388
  this.logger.info("Auto-initializing session", { taskId, runId, mode });
382
389
 
390
+ // Check if this is a resume from a previous run
391
+ const resumeRunId = process.env.POSTHOG_RESUME_RUN_ID;
392
+ if (resumeRunId) {
393
+ this.logger.info("Resuming from previous run", {
394
+ resumeRunId,
395
+ currentRunId: runId,
396
+ });
397
+ try {
398
+ this.resumeState = await resumeFromLog({
399
+ taskId,
400
+ runId: resumeRunId,
401
+ repositoryPath: this.config.repositoryPath,
402
+ apiClient: this.posthogAPI,
403
+ logger: new Logger({ debug: true, prefix: "[Resume]" }),
404
+ });
405
+ this.logger.info("Resume state loaded", {
406
+ conversationTurns: this.resumeState.conversation.length,
407
+ snapshotApplied: this.resumeState.snapshotApplied,
408
+ logEntries: this.resumeState.logEntryCount,
409
+ });
410
+ } catch (error) {
411
+ this.logger.warn("Failed to load resume state, starting fresh", {
412
+ error,
413
+ });
414
+ this.resumeState = null;
415
+ }
416
+ }
417
+
383
418
  // Create a synthetic payload from config (no JWT needed for auto-init)
384
419
  const payload: JwtPayload = {
385
420
  task_id: taskId,
@@ -464,6 +499,8 @@ export class AgentServer {
464
499
  }),
465
500
  });
466
501
 
502
+ this.broadcastTurnComplete(result.stopReason);
503
+
467
504
  return { stopReason: result.stopReason };
468
505
  }
469
506
 
@@ -510,22 +547,23 @@ export class AgentServer {
510
547
 
511
548
  this.configureEnvironment();
512
549
 
550
+ const posthogAPI = new PostHogAPIClient({
551
+ apiUrl: this.config.apiUrl,
552
+ projectId: this.config.projectId,
553
+ getApiKey: () => this.config.apiKey,
554
+ userAgent: `posthog/cloud.hog.dev; version: ${this.config.version ?? packageJson.version}`,
555
+ });
556
+
513
557
  const treeTracker = this.config.repositoryPath
514
558
  ? new TreeTracker({
515
559
  repositoryPath: this.config.repositoryPath,
516
560
  taskId: payload.task_id,
517
561
  runId: payload.run_id,
562
+ apiClient: posthogAPI,
518
563
  logger: new Logger({ debug: true, prefix: "[TreeTracker]" }),
519
564
  })
520
565
  : null;
521
566
 
522
- const posthogAPI = new PostHogAPIClient({
523
- apiUrl: this.config.apiUrl,
524
- projectId: this.config.projectId,
525
- getApiKey: () => this.config.apiKey,
526
- userAgent: `posthog/cloud.hog.dev; version: ${this.config.version ?? packageJson.version}`,
527
- });
528
-
529
567
  const logWriter = new SessionLogWriter({
530
568
  posthogAPI,
531
569
  logger: new Logger({ debug: true, prefix: "[SessionLogWriter]" }),
@@ -653,27 +691,61 @@ export class AgentServer {
653
691
  ): Promise<void> {
654
692
  if (!this.session) return;
655
693
 
656
- try {
657
- const task = await this.posthogAPI.getTask(payload.task_id);
694
+ // Fetch TaskRun early — needed for both resume detection and initial prompt
695
+ let taskRun = prefetchedRun ?? null;
696
+ if (!taskRun) {
697
+ try {
698
+ taskRun = await this.posthogAPI.getTaskRun(
699
+ payload.task_id,
700
+ payload.run_id,
701
+ );
702
+ } catch (error) {
703
+ this.logger.warn("Failed to fetch task run", {
704
+ taskId: payload.task_id,
705
+ runId: payload.run_id,
706
+ error,
707
+ });
708
+ }
709
+ }
658
710
 
659
- let taskRun = prefetchedRun ?? null;
660
- if (!taskRun) {
711
+ // Check for resume if not already loaded from env var in autoInitializeSession
712
+ if (!this.resumeState) {
713
+ const resumeRunId = this.getResumeRunId(taskRun);
714
+ if (resumeRunId) {
715
+ this.logger.info("Resuming from previous run (via TaskRun state)", {
716
+ resumeRunId,
717
+ currentRunId: payload.run_id,
718
+ });
661
719
  try {
662
- taskRun = await this.posthogAPI.getTaskRun(
663
- payload.task_id,
664
- payload.run_id,
665
- );
720
+ this.resumeState = await resumeFromLog({
721
+ taskId: payload.task_id,
722
+ runId: resumeRunId,
723
+ repositoryPath: this.config.repositoryPath,
724
+ apiClient: this.posthogAPI,
725
+ logger: new Logger({ debug: true, prefix: "[Resume]" }),
726
+ });
727
+ this.logger.info("Resume state loaded (via TaskRun state)", {
728
+ conversationTurns: this.resumeState.conversation.length,
729
+ snapshotApplied: this.resumeState.snapshotApplied,
730
+ logEntries: this.resumeState.logEntryCount,
731
+ });
666
732
  } catch (error) {
667
- this.logger.warn(
668
- "Failed to fetch task run for initial prompt override",
669
- {
670
- taskId: payload.task_id,
671
- runId: payload.run_id,
672
- error,
673
- },
674
- );
733
+ this.logger.warn("Failed to load resume state, starting fresh", {
734
+ error,
735
+ });
736
+ this.resumeState = null;
675
737
  }
676
738
  }
739
+ }
740
+
741
+ // Resume flow: if we have resume state, format conversation history as context
742
+ if (this.resumeState && this.resumeState.conversation.length > 0) {
743
+ await this.sendResumeMessage(payload, taskRun);
744
+ return;
745
+ }
746
+
747
+ try {
748
+ const task = await this.posthogAPI.getTask(payload.task_id);
677
749
 
678
750
  const initialPromptOverride = taskRun
679
751
  ? this.getInitialPromptOverride(taskRun)
@@ -700,6 +772,8 @@ export class AgentServer {
700
772
  stopReason: result.stopReason,
701
773
  });
702
774
 
775
+ this.broadcastTurnComplete(result.stopReason);
776
+
703
777
  if (result.stopReason === "end_turn") {
704
778
  await this.relayAgentResponse(payload);
705
779
  }
@@ -712,6 +786,127 @@ export class AgentServer {
712
786
  }
713
787
  }
714
788
 
789
+ private async sendResumeMessage(
790
+ payload: JwtPayload,
791
+ taskRun: TaskRun | null,
792
+ ): Promise<void> {
793
+ if (!this.session || !this.resumeState) return;
794
+
795
+ try {
796
+ const conversationSummary = this.formatConversationForResume(
797
+ this.resumeState.conversation,
798
+ );
799
+
800
+ // Read the pending user message from TaskRun state (set by the workflow
801
+ // when the user sends a follow-up message that triggers a resume).
802
+ const pendingUserMessage = this.getPendingUserMessage(taskRun);
803
+
804
+ const sandboxContext = this.resumeState.snapshotApplied
805
+ ? `The sandbox environment (all files, packages, and code changes) has been fully restored from a snapshot.`
806
+ : `The sandbox could not be restored from a snapshot (it may have expired). You are starting with a fresh environment but have the full conversation history below.`;
807
+
808
+ let resumePrompt: string;
809
+ if (pendingUserMessage) {
810
+ // Include the pending message as the user's new question so the agent
811
+ // responds to it directly instead of the generic resume context.
812
+ resumePrompt =
813
+ `You are resuming a previous conversation. ${sandboxContext}\n\n` +
814
+ `Here is the conversation history from the previous session:\n\n` +
815
+ `${conversationSummary}\n\n` +
816
+ `The user has sent a new message:\n\n` +
817
+ `${pendingUserMessage}\n\n` +
818
+ `Respond to the user's new message above. You have full context from the previous session.`;
819
+ } else {
820
+ resumePrompt =
821
+ `You are resuming a previous conversation. ${sandboxContext}\n\n` +
822
+ `Here is the conversation history from the previous session:\n\n` +
823
+ `${conversationSummary}\n\n` +
824
+ `Continue from where you left off. The user is waiting for your response.`;
825
+ }
826
+
827
+ this.logger.info("Sending resume message", {
828
+ taskId: payload.task_id,
829
+ conversationTurns: this.resumeState.conversation.length,
830
+ promptLength: resumePrompt.length,
831
+ hasPendingUserMessage: !!pendingUserMessage,
832
+ snapshotApplied: this.resumeState.snapshotApplied,
833
+ });
834
+
835
+ // Clear resume state so it's not reused
836
+ this.resumeState = null;
837
+
838
+ const result = await this.session.clientConnection.prompt({
839
+ sessionId: this.session.acpSessionId,
840
+ prompt: [{ type: "text", text: resumePrompt }],
841
+ });
842
+
843
+ this.logger.info("Resume message completed", {
844
+ stopReason: result.stopReason,
845
+ });
846
+
847
+ this.broadcastTurnComplete(result.stopReason);
848
+ } catch (error) {
849
+ this.logger.error("Failed to send resume message", error);
850
+ if (this.session) {
851
+ await this.session.logWriter.flushAll();
852
+ }
853
+ await this.signalTaskComplete(payload, "error");
854
+ }
855
+ }
856
+
857
+ private static RESUME_HISTORY_TOKEN_BUDGET = 50_000;
858
+ private static TOOL_RESULT_MAX_CHARS = 2000;
859
+
860
+ private formatConversationForResume(
861
+ conversation: ConversationTurn[],
862
+ ): string {
863
+ const selected = selectRecentTurns(
864
+ conversation,
865
+ AgentServer.RESUME_HISTORY_TOKEN_BUDGET,
866
+ );
867
+ const parts: string[] = [];
868
+
869
+ if (selected.length < conversation.length) {
870
+ parts.push(
871
+ `*(${conversation.length - selected.length} earlier turns omitted)*`,
872
+ );
873
+ }
874
+
875
+ for (const turn of selected) {
876
+ const role = turn.role === "user" ? "User" : "Assistant";
877
+
878
+ const textParts = turn.content
879
+ .filter((block) => block.type === "text")
880
+ .map((block) => (block as { type: "text"; text: string }).text);
881
+
882
+ if (textParts.length > 0) {
883
+ parts.push(`**${role}**: ${textParts.join("\n")}`);
884
+ }
885
+
886
+ if (turn.toolCalls?.length) {
887
+ const toolSummary = turn.toolCalls
888
+ .map((tc) => {
889
+ let resultStr = "";
890
+ if (tc.result !== undefined) {
891
+ const raw =
892
+ typeof tc.result === "string"
893
+ ? tc.result
894
+ : JSON.stringify(tc.result);
895
+ resultStr =
896
+ raw.length > AgentServer.TOOL_RESULT_MAX_CHARS
897
+ ? ` → ${raw.substring(0, AgentServer.TOOL_RESULT_MAX_CHARS)}...(truncated)`
898
+ : ` → ${raw}`;
899
+ }
900
+ return ` - ${tc.toolName}${resultStr}`;
901
+ })
902
+ .join("\n");
903
+ parts.push(`**${role} (tools)**:\n${toolSummary}`);
904
+ }
905
+ }
906
+
907
+ return parts.join("\n\n");
908
+ }
909
+
715
910
  private getInitialPromptOverride(taskRun: TaskRun): string | null {
716
911
  const state = taskRun.state as Record<string, unknown> | undefined;
717
912
  const override = state?.initial_prompt_override;
@@ -723,6 +918,32 @@ export class AgentServer {
723
918
  return trimmed.length > 0 ? trimmed : null;
724
919
  }
725
920
 
921
+ private getPendingUserMessage(taskRun: TaskRun | null): string | null {
922
+ if (!taskRun) return null;
923
+ const state = taskRun.state as Record<string, unknown> | undefined;
924
+ const message = state?.pending_user_message;
925
+ if (typeof message !== "string") {
926
+ return null;
927
+ }
928
+
929
+ const trimmed = message.trim();
930
+ return trimmed.length > 0 ? trimmed : null;
931
+ }
932
+
933
+ private getResumeRunId(taskRun: TaskRun | null): string | null {
934
+ // Env var takes precedence (set by backend infra)
935
+ const envRunId = process.env.POSTHOG_RESUME_RUN_ID;
936
+ if (envRunId) return envRunId;
937
+
938
+ // Fallback: read from TaskRun state (set by API when creating the run)
939
+ if (!taskRun) return null;
940
+ const state = taskRun.state as Record<string, unknown> | undefined;
941
+ const stateRunId = state?.resume_from_run_id;
942
+ return typeof stateRunId === "string" && stateRunId.trim().length > 0
943
+ ? stateRunId.trim()
944
+ : null;
945
+ }
946
+
726
947
  private buildCloudSystemPrompt(prUrl?: string | null): string {
727
948
  if (prUrl) {
728
949
  return `
@@ -1161,15 +1382,13 @@ Important:
1161
1382
  notification,
1162
1383
  });
1163
1384
 
1164
- // Persist to log writer so cloud runs have tree snapshots
1165
- const { archiveUrl: _, ...paramsWithoutArchive } = snapshotWithDevice;
1166
- const logNotification = {
1167
- ...notification,
1168
- params: paramsWithoutArchive,
1169
- };
1385
+ // Persist full snapshot (including archiveUrl) so resume can restore files.
1386
+ // archiveUrl is a pre-signed S3 URL that expires — if the user resumes
1387
+ // after expiry, ApplySnapshotSaga fails gracefully and the agent continues
1388
+ // with conversation context but a fresh sandbox (snapshotApplied=false).
1170
1389
  this.session.logWriter.appendRawLine(
1171
1390
  this.session.payload.run_id,
1172
- JSON.stringify(logNotification),
1391
+ JSON.stringify(notification),
1173
1392
  );
1174
1393
  }
1175
1394
  } catch (error) {
@@ -1177,6 +1396,22 @@ Important:
1177
1396
  }
1178
1397
  }
1179
1398
 
1399
+ private broadcastTurnComplete(stopReason: string): void {
1400
+ if (!this.session) return;
1401
+ this.broadcastEvent({
1402
+ type: "notification",
1403
+ timestamp: new Date().toISOString(),
1404
+ notification: {
1405
+ jsonrpc: "2.0",
1406
+ method: POSTHOG_NOTIFICATIONS.TURN_COMPLETE,
1407
+ params: {
1408
+ sessionId: this.session.acpSessionId,
1409
+ stopReason,
1410
+ },
1411
+ },
1412
+ });
1413
+ }
1414
+
1180
1415
  private broadcastEvent(event: Record<string, unknown>): void {
1181
1416
  if (this.session?.sseController) {
1182
1417
  this.sendSseEvent(this.session.sseController, event);