@posthog/agent 2.3.93 → 2.3.110
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/dist/agent.js +52 -22
- package/dist/agent.js.map +1 -1
- package/dist/posthog-api.d.ts +6 -2
- package/dist/posthog-api.js +36 -19
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +49 -19
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +49 -19
- package/dist/server/bin.cjs.map +1 -1
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/src/agent.ts +4 -4
- package/src/posthog-api.test.ts +48 -0
- package/src/posthog-api.ts +54 -20
- package/src/session-log-writer.test.ts +87 -0
- package/src/session-log-writer.ts +14 -0
- package/src/types.ts +2 -1
package/dist/types.d.ts
CHANGED
|
@@ -88,7 +88,8 @@ type LogLevel = "debug" | "info" | "warn" | "error";
|
|
|
88
88
|
type OnLogCallback = (level: LogLevel, scope: string, message: string, data?: unknown) => void;
|
|
89
89
|
interface PostHogAPIConfig {
|
|
90
90
|
apiUrl: string;
|
|
91
|
-
getApiKey: () => string
|
|
91
|
+
getApiKey: () => string | Promise<string>;
|
|
92
|
+
refreshApiKey?: () => string | Promise<string>;
|
|
92
93
|
projectId: number;
|
|
93
94
|
userAgent?: string;
|
|
94
95
|
}
|
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -45,17 +45,17 @@ export class Agent {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
private _configureLlmGateway(overrideUrl?: string): {
|
|
48
|
+
private async _configureLlmGateway(overrideUrl?: string): Promise<{
|
|
49
49
|
gatewayUrl: string;
|
|
50
50
|
apiKey: string;
|
|
51
|
-
} | null {
|
|
51
|
+
} | null> {
|
|
52
52
|
if (!this.posthogAPI) {
|
|
53
53
|
return null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
try {
|
|
57
57
|
const gatewayUrl = overrideUrl ?? this.posthogAPI.getLlmGatewayUrl();
|
|
58
|
-
const apiKey = this.posthogAPI.getApiKey();
|
|
58
|
+
const apiKey = await this.posthogAPI.getApiKey();
|
|
59
59
|
|
|
60
60
|
process.env.OPENAI_BASE_URL = `${gatewayUrl}/v1`;
|
|
61
61
|
process.env.OPENAI_API_KEY = apiKey;
|
|
@@ -74,7 +74,7 @@ export class Agent {
|
|
|
74
74
|
taskRunId: string,
|
|
75
75
|
options: TaskExecutionOptions = {},
|
|
76
76
|
): Promise<InProcessAcpConnection> {
|
|
77
|
-
const gatewayConfig = this._configureLlmGateway(options.gatewayUrl);
|
|
77
|
+
const gatewayConfig = await this._configureLlmGateway(options.gatewayUrl);
|
|
78
78
|
this.logger.info("Configured LLM gateway", {
|
|
79
79
|
adapter: options.adapter,
|
|
80
80
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { PostHogAPIClient } from "./posthog-api";
|
|
3
|
+
|
|
4
|
+
const mockFetch = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
7
|
+
|
|
8
|
+
describe("PostHogAPIClient", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("refreshes once when fetching task run logs gets an auth failure", async () => {
|
|
14
|
+
const getApiKey = vi.fn().mockResolvedValue("stale-token");
|
|
15
|
+
const refreshApiKey = vi.fn().mockResolvedValue("fresh-token");
|
|
16
|
+
const client = new PostHogAPIClient({
|
|
17
|
+
apiUrl: "https://app.posthog.com",
|
|
18
|
+
getApiKey,
|
|
19
|
+
refreshApiKey,
|
|
20
|
+
projectId: 1,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
mockFetch
|
|
24
|
+
.mockResolvedValueOnce({
|
|
25
|
+
ok: false,
|
|
26
|
+
status: 401,
|
|
27
|
+
statusText: "Unauthorized",
|
|
28
|
+
})
|
|
29
|
+
.mockResolvedValueOnce({
|
|
30
|
+
ok: true,
|
|
31
|
+
text: vi
|
|
32
|
+
.fn()
|
|
33
|
+
.mockResolvedValue(
|
|
34
|
+
`${JSON.stringify({ type: "notification", notification: { method: "foo" } })}\n`,
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const logs = await client.fetchTaskRunLogs({
|
|
39
|
+
id: "run-1",
|
|
40
|
+
task: "task-1",
|
|
41
|
+
} as never);
|
|
42
|
+
|
|
43
|
+
expect(logs).toHaveLength(1);
|
|
44
|
+
expect(getApiKey).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(refreshApiKey).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/posthog-api.ts
CHANGED
|
@@ -47,27 +47,63 @@ export class PostHogAPIClient {
|
|
|
47
47
|
return host;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
private
|
|
51
|
-
return
|
|
52
|
-
Authorization: `Bearer ${this.config.getApiKey()}`,
|
|
53
|
-
"Content-Type": "application/json",
|
|
54
|
-
"User-Agent": this.config.userAgent ?? DEFAULT_USER_AGENT,
|
|
55
|
-
};
|
|
50
|
+
private isAuthFailure(status: number): boolean {
|
|
51
|
+
return status === 401 || status === 403;
|
|
56
52
|
}
|
|
57
53
|
|
|
58
|
-
private async
|
|
54
|
+
private async resolveApiKey(forceRefresh = false): Promise<string> {
|
|
55
|
+
if (forceRefresh && this.config.refreshApiKey) {
|
|
56
|
+
return this.config.refreshApiKey();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return this.config.getApiKey();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async buildHeaders(
|
|
63
|
+
options: RequestInit,
|
|
64
|
+
forceRefresh = false,
|
|
65
|
+
): Promise<Headers> {
|
|
66
|
+
const headers = new Headers(options.headers);
|
|
67
|
+
headers.set(
|
|
68
|
+
"Authorization",
|
|
69
|
+
`Bearer ${await this.resolveApiKey(forceRefresh)}`,
|
|
70
|
+
);
|
|
71
|
+
headers.set("Content-Type", "application/json");
|
|
72
|
+
headers.set("User-Agent", this.config.userAgent ?? DEFAULT_USER_AGENT);
|
|
73
|
+
return headers;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async performRequest(
|
|
59
77
|
endpoint: string,
|
|
60
|
-
options: RequestInit
|
|
61
|
-
|
|
78
|
+
options: RequestInit,
|
|
79
|
+
forceRefresh = false,
|
|
80
|
+
): Promise<Response> {
|
|
62
81
|
const url = `${this.baseUrl}${endpoint}`;
|
|
63
82
|
|
|
64
|
-
|
|
83
|
+
return fetch(url, {
|
|
65
84
|
...options,
|
|
66
|
-
headers:
|
|
67
|
-
...this.headers,
|
|
68
|
-
...options.headers,
|
|
69
|
-
},
|
|
85
|
+
headers: await this.buildHeaders(options, forceRefresh),
|
|
70
86
|
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async performRequestWithRetry(
|
|
90
|
+
endpoint: string,
|
|
91
|
+
options: RequestInit = {},
|
|
92
|
+
): Promise<Response> {
|
|
93
|
+
let response = await this.performRequest(endpoint, options);
|
|
94
|
+
|
|
95
|
+
if (!response.ok && this.isAuthFailure(response.status)) {
|
|
96
|
+
response = await this.performRequest(endpoint, options, true);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async apiRequest<T>(
|
|
103
|
+
endpoint: string,
|
|
104
|
+
options: RequestInit = {},
|
|
105
|
+
): Promise<T> {
|
|
106
|
+
const response = await this.performRequestWithRetry(endpoint, options);
|
|
71
107
|
|
|
72
108
|
if (!response.ok) {
|
|
73
109
|
let errorMessage: string;
|
|
@@ -87,8 +123,8 @@ export class PostHogAPIClient {
|
|
|
87
123
|
return this.config.projectId;
|
|
88
124
|
}
|
|
89
125
|
|
|
90
|
-
getApiKey(): string {
|
|
91
|
-
return this.
|
|
126
|
+
async getApiKey(forceRefresh = false): Promise<string> {
|
|
127
|
+
return this.resolveApiKey(forceRefresh);
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
getLlmGatewayUrl(): string {
|
|
@@ -228,12 +264,10 @@ export class PostHogAPIClient {
|
|
|
228
264
|
*/
|
|
229
265
|
async fetchTaskRunLogs(taskRun: TaskRun): Promise<StoredEntry[]> {
|
|
230
266
|
const teamId = this.getTeamId();
|
|
267
|
+
const endpoint = `/api/projects/${teamId}/tasks/${taskRun.task}/runs/${taskRun.id}/logs`;
|
|
231
268
|
|
|
232
269
|
try {
|
|
233
|
-
const response = await
|
|
234
|
-
`${this.baseUrl}/api/projects/${teamId}/tasks/${taskRun.task}/runs/${taskRun.id}/logs`,
|
|
235
|
-
{ headers: this.headers },
|
|
236
|
-
);
|
|
270
|
+
const response = await this.performRequestWithRetry(endpoint);
|
|
237
271
|
|
|
238
272
|
if (!response.ok) {
|
|
239
273
|
if (response.status === 404) {
|
|
@@ -168,4 +168,91 @@ describe("SessionLogWriter", () => {
|
|
|
168
168
|
expect(logWriter.isRegistered(sessionId)).toBe(true);
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
describe("flush serialization", () => {
|
|
173
|
+
it("serializes concurrent flush calls so they do not overlap", async () => {
|
|
174
|
+
const sessionId = "s1";
|
|
175
|
+
logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
|
|
176
|
+
|
|
177
|
+
const callOrder: string[] = [];
|
|
178
|
+
let resolveFirst!: () => void;
|
|
179
|
+
const firstBlocked = new Promise<void>((r) => {
|
|
180
|
+
resolveFirst = r;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
mockAppendLog
|
|
184
|
+
.mockImplementationOnce(async () => {
|
|
185
|
+
callOrder.push("first-start");
|
|
186
|
+
// Add a new entry while the first flush is in-flight
|
|
187
|
+
logWriter.appendRawLine(sessionId, JSON.stringify({ method: "b" }));
|
|
188
|
+
await firstBlocked;
|
|
189
|
+
callOrder.push("first-end");
|
|
190
|
+
})
|
|
191
|
+
.mockImplementationOnce(async () => {
|
|
192
|
+
callOrder.push("second-start");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
logWriter.appendRawLine(sessionId, JSON.stringify({ method: "a" }));
|
|
196
|
+
const flush1 = logWriter.flush(sessionId);
|
|
197
|
+
|
|
198
|
+
// Wait for the first flush to be in-flight — "b" is added inside the mock
|
|
199
|
+
await vi.waitFor(() => expect(callOrder).toContain("first-start"));
|
|
200
|
+
|
|
201
|
+
// Queue a second flush for the entry added while first was in-flight
|
|
202
|
+
const flush2 = logWriter.flush(sessionId);
|
|
203
|
+
|
|
204
|
+
// First flush is blocked — second should not have started
|
|
205
|
+
expect(callOrder).not.toContain("second-start");
|
|
206
|
+
|
|
207
|
+
// Unblock first flush
|
|
208
|
+
resolveFirst?.();
|
|
209
|
+
await flush1;
|
|
210
|
+
await flush2;
|
|
211
|
+
|
|
212
|
+
// Second started only after first completed
|
|
213
|
+
expect(callOrder).toEqual(["first-start", "first-end", "second-start"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not lose entries when flushes are serialized", async () => {
|
|
217
|
+
const sessionId = "s1";
|
|
218
|
+
logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
|
|
219
|
+
|
|
220
|
+
let resolveFirst!: () => void;
|
|
221
|
+
const firstBlocked = new Promise<void>((r) => {
|
|
222
|
+
resolveFirst = r;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
mockAppendLog
|
|
226
|
+
.mockImplementationOnce(async () => {
|
|
227
|
+
// Add a new entry while the first flush is in-flight — simulates
|
|
228
|
+
// the agent emitting end_turn while a scheduled flush is sending
|
|
229
|
+
// earlier entries to S3.
|
|
230
|
+
logWriter.appendRawLine(sessionId, JSON.stringify({ method: "b" }));
|
|
231
|
+
await firstBlocked;
|
|
232
|
+
})
|
|
233
|
+
.mockImplementationOnce(async () => undefined);
|
|
234
|
+
|
|
235
|
+
// Batch 1
|
|
236
|
+
logWriter.appendRawLine(sessionId, JSON.stringify({ method: "a" }));
|
|
237
|
+
const flush1 = logWriter.flush(sessionId);
|
|
238
|
+
|
|
239
|
+
// Wait for first flush to be in-flight (and "b" to be added)
|
|
240
|
+
await vi.waitFor(() => expect(mockAppendLog).toHaveBeenCalledTimes(1));
|
|
241
|
+
|
|
242
|
+
// Queue flush for the entry added while first was in-flight
|
|
243
|
+
const flush2 = logWriter.flush(sessionId);
|
|
244
|
+
|
|
245
|
+
resolveFirst?.();
|
|
246
|
+
await flush1;
|
|
247
|
+
await flush2;
|
|
248
|
+
|
|
249
|
+
expect(mockAppendLog).toHaveBeenCalledTimes(2);
|
|
250
|
+
const batch1: StoredNotification[] = mockAppendLog.mock.calls[0][2];
|
|
251
|
+
const batch2: StoredNotification[] = mockAppendLog.mock.calls[1][2];
|
|
252
|
+
expect(batch1).toHaveLength(1);
|
|
253
|
+
expect(batch1[0].notification.method).toBe("a");
|
|
254
|
+
expect(batch2).toHaveLength(1);
|
|
255
|
+
expect(batch2[0].notification.method).toBe("b");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
171
258
|
});
|
|
@@ -40,6 +40,7 @@ export class SessionLogWriter {
|
|
|
40
40
|
private lastFlushAttemptTime: Map<string, number> = new Map();
|
|
41
41
|
private retryCounts: Map<string, number> = new Map();
|
|
42
42
|
private sessions: Map<string, SessionState> = new Map();
|
|
43
|
+
private flushQueues: Map<string, Promise<void>> = new Map();
|
|
43
44
|
|
|
44
45
|
private logger: Logger;
|
|
45
46
|
private localCachePath?: string;
|
|
@@ -155,6 +156,19 @@ export class SessionLogWriter {
|
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
async flush(sessionId: string): Promise<void> {
|
|
159
|
+
// Serialize flushes per session
|
|
160
|
+
const prev = this.flushQueues.get(sessionId) ?? Promise.resolve();
|
|
161
|
+
const next = prev.catch(() => {}).then(() => this._doFlush(sessionId));
|
|
162
|
+
this.flushQueues.set(sessionId, next);
|
|
163
|
+
next.finally(() => {
|
|
164
|
+
if (this.flushQueues.get(sessionId) === next) {
|
|
165
|
+
this.flushQueues.delete(sessionId);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async _doFlush(sessionId: string): Promise<void> {
|
|
158
172
|
const session = this.sessions.get(sessionId);
|
|
159
173
|
if (!session) {
|
|
160
174
|
this.logger.warn("flush: no session found", { sessionId });
|
package/src/types.ts
CHANGED
|
@@ -126,7 +126,8 @@ export type OnLogCallback = (
|
|
|
126
126
|
|
|
127
127
|
export interface PostHogAPIConfig {
|
|
128
128
|
apiUrl: string;
|
|
129
|
-
getApiKey: () => string
|
|
129
|
+
getApiKey: () => string | Promise<string>;
|
|
130
|
+
refreshApiKey?: () => string | Promise<string>;
|
|
130
131
|
projectId: number;
|
|
131
132
|
userAgent?: string;
|
|
132
133
|
}
|