@posthog/agent 2.3.351 → 2.3.354

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.351",
3
+ "version": "2.3.354",
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": {
@@ -86,9 +86,9 @@
86
86
  "tsx": "^4.20.6",
87
87
  "typescript": "^5.5.0",
88
88
  "vitest": "^2.1.8",
89
- "@posthog/shared": "1.0.0",
89
+ "@posthog/git": "1.0.0",
90
90
  "@posthog/enricher": "1.0.0",
91
- "@posthog/git": "1.0.0"
91
+ "@posthog/shared": "1.0.0"
92
92
  },
93
93
  "dependencies": {
94
94
  "@agentclientprotocol/sdk": "0.19.0",
@@ -618,6 +618,32 @@ export type ResultMessageHandlerResult = {
618
618
  };
619
619
  };
620
620
 
621
+ export type AgentErrorClassification =
622
+ | "upstream_stream_terminated"
623
+ | "upstream_connection_error"
624
+ | "agent_error";
625
+
626
+ /**
627
+ * Classify an error string surfaced by the Claude CLI via `is_error: true`
628
+ * result messages. Transient upstream-stream terminations (e.g. the fetch body
629
+ * from the LLM gateway is torn down mid-stream) are retriable; most other
630
+ * errors are not.
631
+ */
632
+ export function classifyAgentError(
633
+ result: string | undefined,
634
+ ): AgentErrorClassification {
635
+ if (!result) return "agent_error";
636
+ const text = result.trim();
637
+ // Anthropic SDK surfaces an undici fetch abort as "API Error: terminated".
638
+ if (/API Error:\s*terminated\b/i.test(text)) {
639
+ return "upstream_stream_terminated";
640
+ }
641
+ if (/API Error:\s*Connection error\b/i.test(text)) {
642
+ return "upstream_connection_error";
643
+ }
644
+ return "agent_error";
645
+ }
646
+
621
647
  export function handleResultMessage(
622
648
  message: SDKResultMessage,
623
649
  ): ResultMessageHandlerResult {
@@ -636,9 +662,13 @@ export function handleResultMessage(
636
662
  return { shouldStop: true, stopReason: "max_tokens", usage };
637
663
  }
638
664
  if (message.is_error) {
665
+ const classification = classifyAgentError(message.result);
639
666
  return {
640
667
  shouldStop: true,
641
- error: RequestError.internalError(undefined, message.result),
668
+ error: RequestError.internalError(
669
+ { classification, result: message.result },
670
+ message.result,
671
+ ),
642
672
  usage,
643
673
  };
644
674
  }
@@ -2,7 +2,10 @@ import type {
2
2
  AgentSideConnection,
3
3
  RequestPermissionResponse,
4
4
  } from "@agentclientprotocol/sdk";
5
- import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
5
+ import type {
6
+ PermissionRuleValue,
7
+ PermissionUpdate,
8
+ } from "@anthropic-ai/claude-agent-sdk";
6
9
  import { text } from "../../../utils/acp-content";
7
10
  import type { Logger } from "../../../utils/logger";
8
11
  import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp";
@@ -347,7 +350,7 @@ async function handleDefaultPermissionFlow(
347
350
  const options = buildPermissionOptions(
348
351
  toolName,
349
352
  toolInput as Record<string, unknown>,
350
- session?.cwd,
353
+ session.settingsManager.getRepoRoot(),
351
354
  suggestions,
352
355
  );
353
356
 
@@ -374,17 +377,19 @@ async function handleDefaultPermissionFlow(
374
377
  response.outcome.optionId === "allow_always")
375
378
  ) {
376
379
  if (response.outcome.optionId === "allow_always") {
380
+ const rules = extractAllowRules(suggestions, toolName);
381
+ try {
382
+ await session.settingsManager.addAllowRules(rules);
383
+ } catch (error) {
384
+ context.logger.warn(
385
+ "[canUseTool] Failed to persist allow rules to repository settings",
386
+ { error: error instanceof Error ? error.message : String(error) },
387
+ );
388
+ }
377
389
  return {
378
390
  behavior: "allow",
379
391
  updatedInput: toolInput as Record<string, unknown>,
380
- updatedPermissions: suggestions ?? [
381
- {
382
- type: "addRules",
383
- rules: [{ toolName }],
384
- behavior: "allow",
385
- destination: "localSettings",
386
- },
387
- ],
392
+ updatedPermissions: buildSessionPermissions(suggestions, rules),
388
393
  };
389
394
  }
390
395
  return {
@@ -429,6 +434,44 @@ function handlePlanFileException(
429
434
  };
430
435
  }
431
436
 
437
+ function extractAllowRules(
438
+ suggestions: PermissionUpdate[] | undefined,
439
+ toolName: string,
440
+ ): PermissionRuleValue[] {
441
+ if (!suggestions || suggestions.length === 0) {
442
+ return [{ toolName }];
443
+ }
444
+ return suggestions
445
+ .filter(
446
+ (update) => update.type === "addRules" && update.behavior === "allow",
447
+ )
448
+ .flatMap((update) => ("rules" in update ? update.rules : []));
449
+ }
450
+
451
+ /**
452
+ * Forwards any non-addRules suggestions from the SDK (e.g. addDirectories)
453
+ * with their destination remapped to `session`. Our own allow rules are
454
+ * persisted via `settingsManager.addAllowRules`, so the SDK must not write
455
+ * them to its default per-cwd location.
456
+ */
457
+ function buildSessionPermissions(
458
+ suggestions: PermissionUpdate[] | undefined,
459
+ rules: PermissionRuleValue[],
460
+ ): PermissionUpdate[] {
461
+ const passthrough = (suggestions ?? [])
462
+ .filter(
463
+ (update) => !(update.type === "addRules" && update.behavior === "allow"),
464
+ )
465
+ .map((update) => ({ ...update, destination: "session" as const }));
466
+ if (rules.length === 0) {
467
+ return passthrough;
468
+ }
469
+ return [
470
+ { type: "addRules", rules, behavior: "allow", destination: "session" },
471
+ ...passthrough,
472
+ ];
473
+ }
474
+
432
475
  function extractDomainFromUrl(url: string): string | null {
433
476
  try {
434
477
  return new URL(url).hostname;
@@ -25,7 +25,7 @@ function permissionOptions(allowAlwaysLabel: string): PermissionOption[] {
25
25
  export function buildPermissionOptions(
26
26
  toolName: string,
27
27
  toolInput: Record<string, unknown>,
28
- cwd?: string,
28
+ repoRoot?: string,
29
29
  suggestions?: PermissionUpdate[],
30
30
  ): PermissionOption[] {
31
31
  if (BASH_TOOLS.has(toolName)) {
@@ -36,11 +36,11 @@ export function buildPermissionOptions(
36
36
 
37
37
  const command = toolInput?.command as string | undefined;
38
38
  const cmdName = command?.split(/\s+/)[0] ?? "this command";
39
- const cwdLabel = cwd ? ` in ${cwd}` : "";
39
+ const scopeLabel = repoRoot ? ` in ${repoRoot}` : "";
40
40
  const label = ruleContent ?? `\`${cmdName}\` commands`;
41
41
 
42
42
  return permissionOptions(
43
- `Yes, and don't ask again for ${label}${cwdLabel}`,
43
+ `Yes, and don't ask again for ${label}${scopeLabel}`,
44
44
  );
45
45
  }
46
46
 
@@ -0,0 +1,22 @@
1
+ import { listWorktrees } from "@posthog/git/queries";
2
+
3
+ /**
4
+ * Resolves the primary worktree (main repository) path for a given cwd.
5
+ *
6
+ * Secondary git worktrees share a `.git` common directory with the primary
7
+ * worktree. Returning the primary worktree path lets us scope per-repo
8
+ * settings — such as "don't ask again" permission rules — to a single
9
+ * location that every worktree of the same repository can read from.
10
+ *
11
+ * `git worktree list --porcelain` always emits the primary worktree first.
12
+ * Returns `cwd` when the directory is not inside a git repository or when
13
+ * git is unavailable.
14
+ */
15
+ export async function resolveMainRepoPath(cwd: string): Promise<string> {
16
+ try {
17
+ const worktrees = await listWorktrees(cwd);
18
+ return worktrees[0]?.path ?? cwd;
19
+ } catch {
20
+ return cwd;
21
+ }
22
+ }
@@ -0,0 +1,159 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { resolveMainRepoPath } from "./repo-path";
7
+ import { SettingsManager } from "./settings";
8
+
9
+ function runGit(cwd: string, args: string[]): void {
10
+ execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
11
+ }
12
+
13
+ describe("SettingsManager per-repo persistence", () => {
14
+ let mainRepo: string;
15
+ let worktree: string;
16
+ let tmpRoot: string;
17
+
18
+ beforeEach(async () => {
19
+ tmpRoot = await fs.promises.realpath(
20
+ await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-manager-")),
21
+ );
22
+ mainRepo = path.join(tmpRoot, "main");
23
+ worktree = path.join(tmpRoot, "wt");
24
+ await fs.promises.mkdir(mainRepo, { recursive: true });
25
+
26
+ runGit(mainRepo, ["init", "-b", "main"]);
27
+ runGit(mainRepo, ["config", "user.email", "test@example.com"]);
28
+ runGit(mainRepo, ["config", "user.name", "test"]);
29
+ runGit(mainRepo, ["commit", "--allow-empty", "-m", "init"]);
30
+ runGit(mainRepo, ["worktree", "add", "-b", "feat", worktree]);
31
+ });
32
+
33
+ afterEach(async () => {
34
+ await fs.promises.rm(tmpRoot, { recursive: true, force: true });
35
+ });
36
+
37
+ it("persists allow rules to the primary worktree when invoked from a secondary worktree", async () => {
38
+ const manager = new SettingsManager(worktree);
39
+ await manager.initialize();
40
+
41
+ await manager.addAllowRules([
42
+ { toolName: "Bash", ruleContent: "pnpm test:*" },
43
+ ]);
44
+
45
+ const repoLocalPath = path.join(mainRepo, ".claude", "settings.local.json");
46
+ const contents = JSON.parse(
47
+ await fs.promises.readFile(repoLocalPath, "utf-8"),
48
+ );
49
+ expect(contents.permissions.allow).toContain("Bash(pnpm test:*)");
50
+
51
+ const worktreeLocalPath = path.join(
52
+ worktree,
53
+ ".claude",
54
+ "settings.local.json",
55
+ );
56
+ expect(fs.existsSync(worktreeLocalPath)).toBe(false);
57
+ });
58
+
59
+ it("sees rules persisted by a sibling worktree after re-initialization", async () => {
60
+ const writer = new SettingsManager(worktree);
61
+ await writer.initialize();
62
+ await writer.addAllowRules([{ toolName: "TodoWrite" }]);
63
+
64
+ const sibling = path.join(tmpRoot, "wt2");
65
+ runGit(mainRepo, ["worktree", "add", "-b", "other", sibling]);
66
+
67
+ const reader = new SettingsManager(sibling);
68
+ await reader.initialize();
69
+ const decision = reader.checkPermission("TodoWrite", {});
70
+ expect(decision.decision).toBe("allow");
71
+ });
72
+
73
+ it("widens name-based matching for argumentless rules", async () => {
74
+ const manager = new SettingsManager(worktree);
75
+ await manager.initialize();
76
+
77
+ await manager.addAllowRules([{ toolName: "TodoWrite" }]);
78
+
79
+ expect(manager.checkPermission("TodoWrite", {}).decision).toBe("allow");
80
+ });
81
+
82
+ it("does not widen name-based matching when the rule has an argument", async () => {
83
+ // A rule *with* an argument for a tool we don't have an accessor for must
84
+ // not match regardless of the actual input — otherwise a deny rule like
85
+ // `Bash(rm -rf)` applied to a non-ACP Bash invocation would match any
86
+ // command.
87
+ const manager = new SettingsManager(worktree);
88
+ await manager.initialize();
89
+
90
+ await manager.addAllowRules([
91
+ { toolName: "UnknownTool", ruleContent: "something" },
92
+ ]);
93
+
94
+ expect(
95
+ manager.checkPermission("UnknownTool", { command: "anything" }).decision,
96
+ ).toBe("ask");
97
+ });
98
+
99
+ it("still allows ACP-prefixed Bash invocations when a Bash(...) rule is persisted", async () => {
100
+ const manager = new SettingsManager(worktree);
101
+ await manager.initialize();
102
+
103
+ await manager.addAllowRules([
104
+ { toolName: "Bash", ruleContent: "pnpm test:*" },
105
+ ]);
106
+
107
+ const decision = manager.checkPermission("mcp__acp__Bash", {
108
+ command: "pnpm test --filter agent",
109
+ });
110
+ expect(decision.decision).toBe("allow");
111
+ });
112
+
113
+ it("refuses to overwrite the file when existing contents cannot be parsed", async () => {
114
+ const manager = new SettingsManager(worktree);
115
+ await manager.initialize();
116
+
117
+ const filePath = path.join(mainRepo, ".claude", "settings.local.json");
118
+ const original = "{ this is not valid json";
119
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
120
+ await fs.promises.writeFile(filePath, original);
121
+
122
+ await expect(
123
+ manager.addAllowRules([{ toolName: "TodoWrite" }]),
124
+ ).rejects.toThrow();
125
+
126
+ // File must be untouched — overwriting would wipe whatever the user had.
127
+ expect(await fs.promises.readFile(filePath, "utf-8")).toBe(original);
128
+ });
129
+
130
+ it("concurrent addAllowRules calls do not clobber each other", async () => {
131
+ const manager = new SettingsManager(worktree);
132
+ await manager.initialize();
133
+
134
+ await Promise.all([
135
+ manager.addAllowRules([{ toolName: "A" }]),
136
+ manager.addAllowRules([{ toolName: "B" }]),
137
+ manager.addAllowRules([{ toolName: "C" }]),
138
+ ]);
139
+
140
+ const filePath = path.join(mainRepo, ".claude", "settings.local.json");
141
+ const contents = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
142
+ expect(contents.permissions.allow).toEqual(
143
+ expect.arrayContaining(["A", "B", "C"]),
144
+ );
145
+ });
146
+ });
147
+
148
+ describe("resolveMainRepoPath", () => {
149
+ it("returns cwd when the directory is not inside a git repository", async () => {
150
+ const tmp = await fs.promises.realpath(
151
+ await fs.promises.mkdtemp(path.join(os.tmpdir(), "repo-path-")),
152
+ );
153
+ try {
154
+ expect(await resolveMainRepoPath(tmp)).toBe(tmp);
155
+ } finally {
156
+ await fs.promises.rm(tmp, { recursive: true, force: true });
157
+ }
158
+ });
159
+ });
@@ -1,7 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import type { PermissionRuleValue } from "@anthropic-ai/claude-agent-sdk";
4
5
  import { minimatch } from "minimatch";
6
+ import { AsyncMutex } from "../../../utils/async-mutex";
7
+ import { resolveMainRepoPath } from "./repo-path";
5
8
 
6
9
  const ACP_TOOL_NAME_PREFIX = "mcp__acp__";
7
10
 
@@ -86,7 +89,8 @@ function matchesRule(
86
89
  const ruleAppliesToTool =
87
90
  (rule.toolName === "Bash" && toolName === acpToolNames.bash) ||
88
91
  (rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
89
- (rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
92
+ (rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName)) ||
93
+ (rule.toolName === toolName && !rule.argument);
90
94
 
91
95
  if (!ruleAppliesToTool) {
92
96
  return false;
@@ -123,6 +127,23 @@ function matchesRule(
123
127
  return matchesGlob(rule.argument, actualArg, cwd);
124
128
  }
125
129
 
130
+ function formatRule(rule: PermissionRuleValue): string {
131
+ return rule.ruleContent
132
+ ? `${rule.toolName}(${rule.ruleContent})`
133
+ : rule.toolName;
134
+ }
135
+
136
+ async function writeFileAtomic(filePath: string, data: string): Promise<void> {
137
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
138
+ await fs.promises.writeFile(tmpPath, data);
139
+ try {
140
+ await fs.promises.rename(tmpPath, filePath);
141
+ } catch (error) {
142
+ await fs.promises.rm(tmpPath, { force: true });
143
+ throw error;
144
+ }
145
+ }
146
+
126
147
  async function loadSettingsFile(
127
148
  filePath: string | undefined,
128
149
  ): Promise<ClaudeCodeSettings> {
@@ -143,6 +164,26 @@ async function loadSettingsFile(
143
164
  }
144
165
  }
145
166
 
167
+ /**
168
+ * Reads a settings file for a read-modify-write cycle. Unlike
169
+ * `loadSettingsFile`, this throws on any error other than ENOENT — we refuse
170
+ * to overwrite a file we couldn't parse, because doing so would wipe the
171
+ * user's existing settings (other allow/deny/ask rules, env, model, etc).
172
+ */
173
+ async function readSettingsFileForUpdate(
174
+ filePath: string,
175
+ ): Promise<ClaudeCodeSettings> {
176
+ try {
177
+ const content = await fs.promises.readFile(filePath, "utf-8");
178
+ return JSON.parse(content) as ClaudeCodeSettings;
179
+ } catch (error) {
180
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
181
+ return {};
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+
146
187
  export interface PermissionSettings {
147
188
  allow?: string[];
148
189
  deny?: string[];
@@ -177,8 +218,10 @@ export function getManagedSettingsPath(): string {
177
218
  return "/etc/claude-code/managed-settings.json";
178
219
  }
179
220
  }
221
+
180
222
  export class SettingsManager {
181
223
  private cwd: string;
224
+ private repoRoot: string;
182
225
  private userSettings: ClaudeCodeSettings = {};
183
226
  private projectSettings: ClaudeCodeSettings = {};
184
227
  private localSettings: ClaudeCodeSettings = {};
@@ -186,9 +229,11 @@ export class SettingsManager {
186
229
  private mergedSettings: ClaudeCodeSettings = {};
187
230
  private initialized = false;
188
231
  private initPromise: Promise<void> | null = null;
232
+ private writeMutex = new AsyncMutex();
189
233
 
190
234
  constructor(cwd: string) {
191
235
  this.cwd = cwd;
236
+ this.repoRoot = cwd;
192
237
  }
193
238
 
194
239
  async initialize(): Promise<void> {
@@ -211,11 +256,17 @@ export class SettingsManager {
211
256
  return path.join(this.cwd, ".claude", "settings.json");
212
257
  }
213
258
 
259
+ /**
260
+ * Local settings are anchored to the primary worktree so every worktree of
261
+ * the same repository shares a single `.claude/settings.local.json`. This
262
+ * avoids re-prompting for the same permission in every worktree.
263
+ */
214
264
  private getLocalSettingsPath(): string {
215
- return path.join(this.cwd, ".claude", "settings.local.json");
265
+ return path.join(this.repoRoot, ".claude", "settings.local.json");
216
266
  }
217
267
 
218
268
  private async loadAllSettings(): Promise<void> {
269
+ this.repoRoot = await resolveMainRepoPath(this.cwd);
219
270
  const [userSettings, projectSettings, localSettings, enterpriseSettings] =
220
271
  await Promise.all([
221
272
  loadSettingsFile(this.getUserSettingsPath()),
@@ -278,10 +329,6 @@ export class SettingsManager {
278
329
  }
279
330
 
280
331
  checkPermission(toolName: string, toolInput: unknown): PermissionCheckResult {
281
- if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) {
282
- return { decision: "ask" };
283
- }
284
-
285
332
  const permissions = this.mergedSettings.permissions;
286
333
  if (!permissions) {
287
334
  return { decision: "ask" };
@@ -319,6 +366,45 @@ export class SettingsManager {
319
366
  return this.cwd;
320
367
  }
321
368
 
369
+ getRepoRoot(): string {
370
+ return this.repoRoot;
371
+ }
372
+
373
+ /**
374
+ * Persists allow rules to `<primary-worktree>/.claude/settings.local.json`.
375
+ * Because local settings are resolved against the primary worktree, every
376
+ * worktree of the same repository picks up the new rule on next load.
377
+ *
378
+ * Writes are serialised via `writeMutex` to prevent concurrent callers from
379
+ * clobbering each other, and use a temp-file + rename to keep the file
380
+ * consistent if the process dies mid-write.
381
+ */
382
+ async addAllowRules(rules: PermissionRuleValue[]): Promise<void> {
383
+ if (rules.length === 0) return;
384
+ if (!this.initialized) await this.initialize();
385
+ await this.writeMutex.acquire();
386
+ try {
387
+ const filePath = this.getLocalSettingsPath();
388
+ const existing = await readSettingsFileForUpdate(filePath);
389
+ const permissions: PermissionSettings = {
390
+ ...(existing.permissions ?? {}),
391
+ };
392
+ const current = new Set(permissions.allow ?? []);
393
+ for (const rule of rules) {
394
+ current.add(formatRule(rule));
395
+ }
396
+ permissions.allow = Array.from(current);
397
+ const next: ClaudeCodeSettings = { ...existing, permissions };
398
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
399
+ await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`);
400
+
401
+ this.localSettings = next;
402
+ this.mergeAllSettings();
403
+ } finally {
404
+ this.writeMutex.release();
405
+ }
406
+ }
407
+
322
408
  async setCwd(cwd: string): Promise<void> {
323
409
  if (this.cwd === cwd) return;
324
410
  if (this.initPromise) await this.initPromise;
@@ -14,12 +14,17 @@ import {
14
14
  import { type ServerType, serve } from "@hono/node-server";
15
15
  import { getCurrentBranch } from "@posthog/git/queries";
16
16
  import { Hono } from "hono";
17
+ import { z } from "zod";
17
18
  import packageJson from "../../package.json" with { type: "json" };
18
19
  import { POSTHOG_METHODS, POSTHOG_NOTIFICATIONS } from "../acp-extensions";
19
20
  import {
20
21
  createAcpConnection,
21
22
  type InProcessAcpConnection,
22
23
  } from "../adapters/acp-connection";
24
+ import {
25
+ type AgentErrorClassification,
26
+ classifyAgentError,
27
+ } from "../adapters/claude/conversion/sdk-to-acp";
23
28
  import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration";
24
29
  import type { PermissionMode } from "../execution-mode";
25
30
  import { DEFAULT_CODEX_MODEL } from "../gateway-models";
@@ -51,6 +56,16 @@ import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt";
51
56
  import { jsonRpcRequestSchema, validateCommandParams } from "./schemas";
52
57
  import type { AgentServerConfig } from "./types";
53
58
 
59
+ const agentErrorClassificationSchema = z.enum([
60
+ "upstream_stream_terminated",
61
+ "upstream_connection_error",
62
+ "agent_error",
63
+ ]) satisfies z.ZodType<AgentErrorClassification>;
64
+
65
+ const errorWithClassificationSchema = z.object({
66
+ data: z.object({ classification: agentErrorClassificationSchema }),
67
+ });
68
+
54
69
  type MessageCallback = (message: unknown) => void;
55
70
 
56
71
  class NdJsonTap {
@@ -973,6 +988,41 @@ export class AgentServer {
973
988
  await this.sendInitialTaskMessage(payload, preTaskRun);
974
989
  }
975
990
 
991
+ private extractErrorClassification(error: unknown): {
992
+ classification: AgentErrorClassification;
993
+ message: string;
994
+ } {
995
+ const message =
996
+ error instanceof Error ? error.message : String(error ?? "");
997
+
998
+ // Prefer the structured `data` carried on RequestError if present.
999
+ const parsed = errorWithClassificationSchema.safeParse(error);
1000
+ if (parsed.success) {
1001
+ return { classification: parsed.data.data.classification, message };
1002
+ }
1003
+
1004
+ return { classification: classifyAgentError(message), message };
1005
+ }
1006
+
1007
+ private classifyAndSignalFailure(
1008
+ payload: JwtPayload,
1009
+ phase: "initial" | "resume",
1010
+ error: unknown,
1011
+ ): Promise<void> {
1012
+ const { classification, message } = this.extractErrorClassification(error);
1013
+ const errorMessage =
1014
+ classification === "upstream_stream_terminated"
1015
+ ? "Upstream LLM stream terminated"
1016
+ : classification === "upstream_connection_error"
1017
+ ? "Upstream LLM connection error"
1018
+ : message || "Agent error";
1019
+ this.logger.error(`send_${phase}_task_message_failed`, {
1020
+ classification,
1021
+ message,
1022
+ });
1023
+ return this.signalTaskComplete(payload, "error", errorMessage);
1024
+ }
1025
+
976
1026
  private async sendInitialTaskMessage(
977
1027
  payload: JwtPayload,
978
1028
  prefetchedRun?: TaskRun | null,
@@ -1087,7 +1137,7 @@ export class AgentServer {
1087
1137
  if (this.session) {
1088
1138
  await this.session.logWriter.flushAll();
1089
1139
  }
1090
- await this.signalTaskComplete(payload, "error");
1140
+ await this.classifyAndSignalFailure(payload, "initial", error);
1091
1141
  }
1092
1142
  }
1093
1143
 
@@ -1176,7 +1226,7 @@ export class AgentServer {
1176
1226
  if (this.session) {
1177
1227
  await this.session.logWriter.flushAll();
1178
1228
  }
1179
- await this.signalTaskComplete(payload, "error");
1229
+ await this.classifyAndSignalFailure(payload, "resume", error);
1180
1230
  }
1181
1231
  }
1182
1232
 
@@ -1657,6 +1707,7 @@ ${attributionInstructions}
1657
1707
  private async signalTaskComplete(
1658
1708
  payload: JwtPayload,
1659
1709
  stopReason: string,
1710
+ errorMessage?: string,
1660
1711
  ): Promise<void> {
1661
1712
  if (this.session?.payload.run_id === payload.run_id) {
1662
1713
  try {
@@ -1684,7 +1735,7 @@ ${attributionInstructions}
1684
1735
  try {
1685
1736
  await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
1686
1737
  status,
1687
- error_message: stopReason === "error" ? "Agent error" : undefined,
1738
+ error_message: errorMessage ?? "Agent error",
1688
1739
  });
1689
1740
  this.logger.info("Task completion signaled", { status, stopReason });
1690
1741
  } catch (error) {