@vauban-org/agent-sdk 1.2.0 → 1.3.0

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.
Files changed (82) hide show
  1. package/CONTRACT.md +595 -7
  2. package/dist/index.d.ts +4 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/orchestration/ooda/agent.d.ts.map +1 -1
  7. package/dist/orchestration/ooda/agent.js +36 -0
  8. package/dist/orchestration/ooda/agent.js.map +1 -1
  9. package/dist/orchestration/ooda/types.d.ts +11 -0
  10. package/dist/orchestration/ooda/types.d.ts.map +1 -1
  11. package/dist/skills/_secrets.d.ts +16 -0
  12. package/dist/skills/_secrets.d.ts.map +1 -0
  13. package/dist/skills/_secrets.js +20 -0
  14. package/dist/skills/_secrets.js.map +1 -0
  15. package/dist/skills/alpaca-quote.d.ts +2 -2
  16. package/dist/skills/alpaca-quote.d.ts.map +1 -1
  17. package/dist/skills/alpaca-quote.js +51 -20
  18. package/dist/skills/alpaca-quote.js.map +1 -1
  19. package/dist/skills/send-email.d.ts +2 -2
  20. package/dist/skills/send-email.d.ts.map +1 -1
  21. package/dist/skills/send-email.js +1 -12
  22. package/dist/skills/send-email.js.map +1 -1
  23. package/dist/skills/slack-notify.d.ts.map +1 -1
  24. package/dist/skills/slack-notify.js +52 -21
  25. package/dist/skills/slack-notify.js.map +1 -1
  26. package/dist/skills/telegram-notify.d.ts.map +1 -1
  27. package/dist/skills/telegram-notify.js +48 -19
  28. package/dist/skills/telegram-notify.js.map +1 -1
  29. package/dist/skills/web-search.d.ts.map +1 -1
  30. package/dist/skills/web-search.js +85 -40
  31. package/dist/skills/web-search.js.map +1 -1
  32. package/dist/telemetry/bus.d.ts +54 -0
  33. package/dist/telemetry/bus.d.ts.map +1 -0
  34. package/dist/telemetry/bus.js +159 -0
  35. package/dist/telemetry/bus.js.map +1 -0
  36. package/dist/telemetry/index.d.ts +35 -0
  37. package/dist/telemetry/index.d.ts.map +1 -0
  38. package/dist/telemetry/index.js +30 -0
  39. package/dist/telemetry/index.js.map +1 -0
  40. package/dist/telemetry/port.d.ts +121 -0
  41. package/dist/telemetry/port.d.ts.map +1 -0
  42. package/dist/telemetry/port.js +48 -0
  43. package/dist/telemetry/port.js.map +1 -0
  44. package/dist/telemetry/sinks/otlp.d.ts +45 -0
  45. package/dist/telemetry/sinks/otlp.d.ts.map +1 -0
  46. package/dist/telemetry/sinks/otlp.js +195 -0
  47. package/dist/telemetry/sinks/otlp.js.map +1 -0
  48. package/dist/telemetry/sinks/sqlite.d.ts +32 -0
  49. package/dist/telemetry/sinks/sqlite.d.ts.map +1 -0
  50. package/dist/telemetry/sinks/sqlite.js +170 -0
  51. package/dist/telemetry/sinks/sqlite.js.map +1 -0
  52. package/dist/telemetry/sinks/stdout.d.ts +22 -0
  53. package/dist/telemetry/sinks/stdout.d.ts.map +1 -0
  54. package/dist/telemetry/sinks/stdout.js +38 -0
  55. package/dist/telemetry/sinks/stdout.js.map +1 -0
  56. package/docs/telemetry/migration.md +155 -0
  57. package/docs/telemetry/overview.md +154 -0
  58. package/docs/telemetry/privacy.md +127 -0
  59. package/docs/telemetry/sinks/cc.md +155 -0
  60. package/docs/telemetry/sinks/otlp.md +146 -0
  61. package/docs/telemetry/sinks/sqlite.md +126 -0
  62. package/docs/telemetry/sinks/stdout.md +82 -0
  63. package/package.json +18 -19
  64. package/src/index.ts +30 -1
  65. package/src/orchestration/ooda/agent.ts +50 -0
  66. package/src/orchestration/ooda/types.ts +12 -0
  67. package/src/skills/_secrets.ts +25 -0
  68. package/src/skills/alpaca-quote.ts +68 -23
  69. package/src/skills/send-email.ts +1 -12
  70. package/src/skills/slack-notify.ts +73 -30
  71. package/src/skills/telegram-notify.ts +70 -24
  72. package/src/skills/web-search.ts +132 -50
  73. package/src/telemetry/bus.test.ts +231 -0
  74. package/src/telemetry/bus.ts +241 -0
  75. package/src/telemetry/index.ts +49 -0
  76. package/src/telemetry/port.ts +160 -0
  77. package/src/telemetry/sinks/otlp.test.ts +146 -0
  78. package/src/telemetry/sinks/otlp.ts +250 -0
  79. package/src/telemetry/sinks/sqlite.test.ts +121 -0
  80. package/src/telemetry/sinks/sqlite.ts +260 -0
  81. package/src/telemetry/sinks/stdout.test.ts +109 -0
  82. package/src/telemetry/sinks/stdout.ts +59 -0
package/src/index.ts CHANGED
@@ -105,10 +105,39 @@ export { createAgentRunTracker } from "./tracking/agent-run-tracker.js";
105
105
  export type {
106
106
  AgentRunTracker,
107
107
  AgentRunStartInput,
108
- AgentRunStepDelta,
109
108
  AgentRunFinish,
110
109
  DbClient,
111
110
  } from "./tracking/agent-run-tracker.js";
111
+
112
+ // ─── Telemetry — sovereign multi-sink observability (ADR-ECO-039) ────────────
113
+ export {
114
+ NOOP_TELEMETRY_SINK,
115
+ TelemetrySinkError,
116
+ createTelemetryBus,
117
+ stdoutTelemetrySink,
118
+ localSqliteTelemetrySink,
119
+ otlpTelemetrySink,
120
+ } from "./telemetry/index.js";
121
+ export type {
122
+ TelemetrySink,
123
+ TelemetryRunStart,
124
+ TelemetryRunStep,
125
+ TelemetryRunFinish,
126
+ TelemetryRunStatus,
127
+ TelemetryBusOptions,
128
+ TelemetryCounters,
129
+ TelemetryLogger,
130
+ StdoutTelemetrySinkOptions,
131
+ LocalSqliteTelemetrySinkOptions,
132
+ OtlpTelemetrySinkOptions,
133
+ } from "./telemetry/index.js";
134
+
135
+ // Re-export legacy AgentRunStepDelta + AgentRunFinalStatus (kept for backward
136
+ // compat with `AgentRunTracker` consumers, e.g. command-center/src/tracking).
137
+ export type {
138
+ AgentRunStepDelta,
139
+ AgentRunFinalStatus,
140
+ } from "./tracking/agent-run-tracker.js";
112
141
  export { createBullMQRunner, BullMQRunner } from "./durable/bullmq-runner.js";
113
142
  export type {
114
143
  BullMQRunnerConfig,
@@ -295,6 +295,51 @@ export class OODAAgentImpl<
295
295
  const executionMode = overrideMode ?? this._config.executionMode;
296
296
  const isReplay = false;
297
297
  const logger = this._config.logger ?? NOOP_LOGGER;
298
+ const startedAt = new Date().toISOString();
299
+
300
+ // ── Telemetry (ADR-ECO-039) ─────────────────────────────────────────────
301
+ // Failures isolated by sink/bus — never block agent. Fire-and-forget on
302
+ // exit paths so cycle latency is unaffected.
303
+ const telemetry = this._config.telemetry;
304
+ const telemetryStartFired = telemetry
305
+ ? telemetry
306
+ .start({
307
+ runId,
308
+ agentId: this._config.agentId,
309
+ agentVersion: this._config.agentVersion ?? "0.0.0",
310
+ model: "unknown",
311
+ provider: "unknown",
312
+ startedAt,
313
+ })
314
+ .catch((err: unknown) => {
315
+ logger.warn(
316
+ { err: (err as Error)?.message ?? String(err) },
317
+ "telemetry.start.failed"
318
+ );
319
+ })
320
+ : Promise.resolve();
321
+ const finishTelemetry = (
322
+ status: "success" | "failed" | "skipped",
323
+ stopReason?: string,
324
+ errorMessage?: string
325
+ ): void => {
326
+ if (!telemetry) return;
327
+ void telemetryStartFired.then(() =>
328
+ telemetry
329
+ .finish(runId, {
330
+ status,
331
+ stopReason,
332
+ errorMessage,
333
+ finishedAt: new Date().toISOString(),
334
+ })
335
+ .catch((err: unknown) =>
336
+ logger.warn(
337
+ { err: (err as Error)?.message ?? String(err) },
338
+ "telemetry.finish.failed"
339
+ )
340
+ )
341
+ );
342
+ };
298
343
 
299
344
  // ── OTel: agent-level span for the full cycle ───────────────────────────
300
345
  const tracer = getTracer("vauban-agent-sdk.ooda");
@@ -369,6 +414,7 @@ export class OODAAgentImpl<
369
414
  rootSpan.end();
370
415
  this._cyclesCompleted += 1;
371
416
  this._lastCycleAt = new Date().toISOString();
417
+ finishTelemetry("skipped", sessionResult.reason ?? "session_guard");
372
418
  return { runId, status: "skipped" };
373
419
  }
374
420
 
@@ -391,6 +437,7 @@ export class OODAAgentImpl<
391
437
  rootSpan.end();
392
438
  this._cyclesCompleted += 1;
393
439
  this._lastCycleAt = new Date().toISOString();
440
+ finishTelemetry("skipped", riskResult.reason ?? "risk_guard");
394
441
  return { runId, status: "skipped" };
395
442
  }
396
443
 
@@ -410,6 +457,7 @@ export class OODAAgentImpl<
410
457
  rootSpan.end();
411
458
  this._cyclesCompleted += 1;
412
459
  this._lastCycleAt = new Date().toISOString();
460
+ finishTelemetry("skipped", `heap_exceeded:${heapMb.toFixed(1)}mb`);
413
461
  return { runId, status: "skipped" };
414
462
  }
415
463
 
@@ -489,6 +537,7 @@ export class OODAAgentImpl<
489
537
  rootSpan.end();
490
538
  this._cyclesCompleted += 1;
491
539
  this._lastCycleAt = new Date().toISOString();
540
+ finishTelemetry("success");
492
541
  return { runId, status: "succeeded" };
493
542
  } catch (err) {
494
543
  logger.error(
@@ -509,6 +558,7 @@ export class OODAAgentImpl<
509
558
  rootSpan.end();
510
559
  this._cyclesCompleted += 1;
511
560
  this._lastCycleAt = new Date().toISOString();
561
+ finishTelemetry("failed", undefined, (err as Error).message);
512
562
  return { runId, status: "failed" };
513
563
  }
514
564
  }
@@ -282,6 +282,18 @@ export interface OODAAgentConfig<
282
282
  readonly errorStepImpl?: OODAContext["errorStep"];
283
283
  readonly notifySlackImpl?: OODAContext["notifySlack"];
284
284
 
285
+ /**
286
+ * Optional telemetry sink (or a {@link createTelemetryBus} for multi-sink).
287
+ *
288
+ * Per ADR-ECO-039. Receives one `start`, 0..N `step`, and one `finish` event
289
+ * per OODA cycle. Failures are isolated by the bus — never block the agent.
290
+ * When absent, telemetry is silently skipped.
291
+ *
292
+ * @experimental — public-experimental contract (interface stable as of 1.3.0,
293
+ * additional event fields may be added before 2.0).
294
+ */
295
+ readonly telemetry?: import("../../telemetry/port.js").TelemetrySink;
296
+
285
297
  /**
286
298
  * Optional HITL gate awaiter. Called when `act` phase has
287
299
  * `hitlGate: true`. Resolves when human approves; rejects to abort.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Internal helper for skills that read configuration secrets.
3
+ *
4
+ * Prefers `ctx.secrets` (audit-trail-bearing per ADR-ECO-036) over
5
+ * `process.env` when a {@link SecretsAccessor} is configured on the
6
+ * {@link SkillContext}. Falls back to env when no accessor is wired so
7
+ * legacy callers see no behavior change.
8
+ *
9
+ * Returns `undefined` when the secret is configured nowhere — callers
10
+ * decide whether that's a SkillNotConfiguredError or a soft default.
11
+ *
12
+ * @internal
13
+ */
14
+
15
+ import type { SkillContext } from "../orchestration/ooda/skills.js";
16
+
17
+ export function readSecret(
18
+ ctx: SkillContext,
19
+ name: string
20
+ ): string | undefined {
21
+ if (ctx.secrets && ctx.secrets.has(name)) {
22
+ return ctx.secrets.get(name);
23
+ }
24
+ return process.env[name];
25
+ }
@@ -6,9 +6,31 @@
6
6
  * @public
7
7
  */
8
8
  import { z } from "zod";
9
+
10
+ import {
11
+ retry,
12
+ RETRY_TRANSIENT,
13
+ RetryExhaustedError,
14
+ type RetryConfig,
15
+ } from "../retry/index.js";
9
16
  import type { Skill, SkillContext } from "../orchestration/ooda/skills.js";
17
+
10
18
  import { SkillExecutionError, SkillNotConfiguredError } from "./errors.js";
11
19
  import { withSkillSpan } from "./_otel.js";
20
+ import { readSecret } from "./_secrets.js";
21
+
22
+ class RetryableHttpError extends Error {
23
+ constructor(public readonly status: number, public readonly body: string) {
24
+ super(`HTTP ${status}: ${body.slice(0, 200)}`);
25
+ this.name = "RetryableHttpError";
26
+ }
27
+ }
28
+
29
+ const ALPACA_RETRY: RetryConfig = {
30
+ ...RETRY_TRANSIENT,
31
+ retryIf: (err) =>
32
+ err instanceof RetryableHttpError || err instanceof TypeError,
33
+ };
12
34
 
13
35
  const inputSchema = z
14
36
  .object({
@@ -46,8 +68,8 @@ export const alpacaQuote: Skill<AlpacaQuoteInput, AlpacaQuoteOutput> = {
46
68
  };
47
69
  }
48
70
  return withSkillSpan("alpaca_quote", async () => {
49
- const apiKey = process.env.ALPACA_API_KEY;
50
- const apiSecret = process.env.ALPACA_API_SECRET;
71
+ const apiKey = readSecret(ctx, "ALPACA_API_KEY");
72
+ const apiSecret = readSecret(ctx, "ALPACA_API_SECRET");
51
73
  if (!apiKey || !apiSecret) {
52
74
  throw new SkillNotConfiguredError("alpaca_quote", [
53
75
  "ALPACA_API_KEY",
@@ -55,28 +77,51 @@ export const alpacaQuote: Skill<AlpacaQuoteInput, AlpacaQuoteOutput> = {
55
77
  ]);
56
78
  }
57
79
  const dataHost = "https://data.alpaca.markets";
58
- const url = `${dataHost}/v2/stocks/${encodeURIComponent(input.symbol)}/quotes/latest`;
59
- const res = await fetch(url, {
60
- headers: {
61
- "APCA-API-KEY-ID": apiKey,
62
- "APCA-API-SECRET-KEY": apiSecret,
63
- },
64
- });
65
- if (!res.ok) {
66
- throw new SkillExecutionError("alpaca_quote", `${res.status}`, {
67
- status: res.status,
68
- });
80
+ const url = `${dataHost}/v2/stocks/${encodeURIComponent(
81
+ input.symbol
82
+ )}/quotes/latest`;
83
+ try {
84
+ return await retry(
85
+ async () => {
86
+ const res = await fetch(url, {
87
+ headers: {
88
+ "APCA-API-KEY-ID": apiKey,
89
+ "APCA-API-SECRET-KEY": apiSecret,
90
+ },
91
+ });
92
+ if (!res.ok) {
93
+ const body = await res.text().catch(() => "");
94
+ if (res.status >= 500 || res.status === 429) {
95
+ throw new RetryableHttpError(res.status, body);
96
+ }
97
+ throw new SkillExecutionError("alpaca_quote", `${res.status}`, {
98
+ status: res.status,
99
+ });
100
+ }
101
+ const data = (await res.json()) as {
102
+ quote?: { bp?: number; ap?: number; t?: string };
103
+ };
104
+ return {
105
+ symbol: input.symbol,
106
+ bid: data.quote?.bp ?? 0,
107
+ ask: data.quote?.ap ?? 0,
108
+ timestamp: data.quote?.t ?? new Date().toISOString(),
109
+ mode: input.mode,
110
+ };
111
+ },
112
+ { config: ALPACA_RETRY }
113
+ );
114
+ } catch (err) {
115
+ const cause = err instanceof RetryExhaustedError ? err.lastError : err;
116
+ if (cause instanceof RetryableHttpError) {
117
+ throw new SkillExecutionError(
118
+ "alpaca_quote",
119
+ `${cause.status} after retries`,
120
+ { status: cause.status }
121
+ );
122
+ }
123
+ throw cause;
69
124
  }
70
- const data = (await res.json()) as {
71
- quote?: { bp?: number; ap?: number; t?: string };
72
- };
73
- return {
74
- symbol: input.symbol,
75
- bid: data.quote?.bp ?? 0,
76
- ask: data.quote?.ap ?? 0,
77
- timestamp: data.quote?.t ?? new Date().toISOString(),
78
- mode: input.mode,
79
- };
80
125
  });
81
126
  },
82
127
  };
@@ -11,6 +11,7 @@ import { z } from "zod";
11
11
  import type { Skill, SkillContext } from "../orchestration/ooda/skills.js";
12
12
  import { SkillExecutionError, SkillNotConfiguredError } from "./errors.js";
13
13
  import { withSkillSpan } from "./_otel.js";
14
+ import { readSecret } from "./_secrets.js";
14
15
 
15
16
  const inputSchema = z
16
17
  .object({
@@ -29,18 +30,6 @@ export interface SendEmailOutput {
29
30
  message_id: string | null;
30
31
  }
31
32
 
32
- /**
33
- * Prefer ctx.secrets (audit-trail-bearing) over process.env. When no secrets
34
- * accessor is configured, falls back to env so legacy callers see no behavior
35
- * change. When the secret is absent in both, returns undefined.
36
- */
37
- function readSecret(ctx: SkillContext, name: string): string | undefined {
38
- if (ctx.secrets) {
39
- if (ctx.secrets.has(name)) return ctx.secrets.get(name);
40
- }
41
- return process.env[name];
42
- }
43
-
44
33
  export const sendEmail: Skill<SendEmailInput, SendEmailOutput> = {
45
34
  name: "send_email",
46
35
  inputSchema,
@@ -6,9 +6,31 @@
6
6
  * @public
7
7
  */
8
8
  import { z } from "zod";
9
+
10
+ import {
11
+ retry,
12
+ RETRY_TRANSIENT,
13
+ RetryExhaustedError,
14
+ type RetryConfig,
15
+ } from "../retry/index.js";
9
16
  import type { Skill, SkillContext } from "../orchestration/ooda/skills.js";
17
+
10
18
  import { SkillExecutionError, SkillNotConfiguredError } from "./errors.js";
11
19
  import { withSkillSpan } from "./_otel.js";
20
+ import { readSecret } from "./_secrets.js";
21
+
22
+ class RetryableHttpError extends Error {
23
+ constructor(public readonly status: number, public readonly body: string) {
24
+ super(`HTTP ${status}: ${body.slice(0, 200)}`);
25
+ this.name = "RetryableHttpError";
26
+ }
27
+ }
28
+
29
+ const SLACK_RETRY: RetryConfig = {
30
+ ...RETRY_TRANSIENT,
31
+ retryIf: (err) =>
32
+ err instanceof RetryableHttpError || err instanceof TypeError,
33
+ };
12
34
 
13
35
  const inputSchema = z
14
36
  .object({
@@ -38,7 +60,7 @@ export const slackNotify: Skill<SlackNotifyInput, SlackNotifyOutput> = {
38
60
  return { delivered: false, ts: null };
39
61
  }
40
62
  return withSkillSpan("slack_notify", async () => {
41
- const token = process.env.SLACK_BOT_TOKEN;
63
+ const token = readSecret(ctx, "SLACK_BOT_TOKEN");
42
64
  if (!token) {
43
65
  throw new SkillNotConfiguredError("slack_notify", ["SLACK_BOT_TOKEN"]);
44
66
  }
@@ -46,45 +68,66 @@ export const slackNotify: Skill<SlackNotifyInput, SlackNotifyOutput> = {
46
68
  if (!channel && input.binding_user_id) {
47
69
  const { rows } = await ctx.db.query<{ chat_id: string }>(
48
70
  "SELECT chat_id FROM messaging_user_binding WHERE user_id = $1 AND channel = 'slack' LIMIT 1",
49
- [input.binding_user_id],
71
+ [input.binding_user_id]
50
72
  );
51
73
  channel = rows[0]?.chat_id;
52
74
  if (!channel) {
53
75
  throw new SkillExecutionError(
54
76
  "slack_notify",
55
- `no slack binding for user ${input.binding_user_id}`,
77
+ `no slack binding for user ${input.binding_user_id}`
56
78
  );
57
79
  }
58
80
  }
59
- const res = await fetch("https://slack.com/api/chat.postMessage", {
60
- method: "POST",
61
- headers: {
62
- "Content-Type": "application/json; charset=utf-8",
63
- Authorization: `Bearer ${token}`,
64
- },
65
- body: JSON.stringify({
66
- channel,
67
- text: input.text,
68
- blocks: input.blocks,
69
- }),
70
- });
71
- if (!res.ok) {
72
- throw new SkillExecutionError("slack_notify", `${res.status}`, {
73
- status: res.status,
74
- });
75
- }
76
- const data = (await res.json()) as {
77
- ok?: boolean;
78
- ts?: string;
79
- error?: string;
80
- };
81
- if (!data.ok) {
82
- throw new SkillExecutionError(
83
- "slack_notify",
84
- data.error ?? "unknown slack error",
81
+ try {
82
+ return await retry(
83
+ async () => {
84
+ const res = await fetch("https://slack.com/api/chat.postMessage", {
85
+ method: "POST",
86
+ headers: {
87
+ "Content-Type": "application/json; charset=utf-8",
88
+ Authorization: `Bearer ${token}`,
89
+ },
90
+ body: JSON.stringify({
91
+ channel,
92
+ text: input.text,
93
+ blocks: input.blocks,
94
+ }),
95
+ });
96
+ if (!res.ok) {
97
+ const body = await res.text().catch(() => "");
98
+ if (res.status >= 500 || res.status === 429) {
99
+ throw new RetryableHttpError(res.status, body);
100
+ }
101
+ throw new SkillExecutionError("slack_notify", `${res.status}`, {
102
+ status: res.status,
103
+ });
104
+ }
105
+ const data = (await res.json()) as {
106
+ ok?: boolean;
107
+ ts?: string;
108
+ error?: string;
109
+ };
110
+ if (!data.ok) {
111
+ throw new SkillExecutionError(
112
+ "slack_notify",
113
+ data.error ?? "unknown slack error"
114
+ );
115
+ }
116
+ return { delivered: true, ts: data.ts ?? null };
117
+ },
118
+ { config: SLACK_RETRY }
85
119
  );
120
+ } catch (err) {
121
+ const cause = err instanceof RetryExhaustedError ? err.lastError : err;
122
+ if (cause instanceof RetryableHttpError) {
123
+ throw new SkillExecutionError(
124
+ "slack_notify",
125
+ `${cause.status} after retries`,
126
+ { status: cause.status }
127
+ );
128
+ }
129
+ throw cause;
86
130
  }
87
- return { delivered: true, ts: data.ts ?? null };
88
131
  });
89
132
  },
90
133
  };
@@ -7,9 +7,31 @@
7
7
  * @public
8
8
  */
9
9
  import { z } from "zod";
10
+
11
+ import {
12
+ retry,
13
+ RETRY_TRANSIENT,
14
+ RetryExhaustedError,
15
+ type RetryConfig,
16
+ } from "../retry/index.js";
10
17
  import type { Skill, SkillContext } from "../orchestration/ooda/skills.js";
18
+
11
19
  import { SkillExecutionError, SkillNotConfiguredError } from "./errors.js";
12
20
  import { withSkillSpan } from "./_otel.js";
21
+ import { readSecret } from "./_secrets.js";
22
+
23
+ class RetryableHttpError extends Error {
24
+ constructor(public readonly status: number, public readonly body: string) {
25
+ super(`HTTP ${status}: ${body.slice(0, 200)}`);
26
+ this.name = "RetryableHttpError";
27
+ }
28
+ }
29
+
30
+ const TELEGRAM_RETRY: RetryConfig = {
31
+ ...RETRY_TRANSIENT,
32
+ retryIf: (err) =>
33
+ err instanceof RetryableHttpError || err instanceof TypeError,
34
+ };
13
35
 
14
36
  const inputSchema = z
15
37
  .object({
@@ -40,7 +62,7 @@ export const telegramNotify: Skill<TelegramNotifyInput, TelegramNotifyOutput> =
40
62
  return { delivered: false, message_id: null };
41
63
  }
42
64
  return withSkillSpan("telegram_notify", async () => {
43
- const token = process.env.TELEGRAM_BOT_TOKEN;
65
+ const token = readSecret(ctx, "TELEGRAM_BOT_TOKEN");
44
66
  if (!token) {
45
67
  throw new SkillNotConfiguredError("telegram_notify", [
46
68
  "TELEGRAM_BOT_TOKEN",
@@ -50,39 +72,63 @@ export const telegramNotify: Skill<TelegramNotifyInput, TelegramNotifyOutput> =
50
72
  if (!chatId && input.binding_user_id) {
51
73
  const { rows } = await ctx.db.query<{ chat_id: string }>(
52
74
  "SELECT chat_id FROM messaging_user_binding WHERE user_id = $1 AND channel = 'telegram' LIMIT 1",
53
- [input.binding_user_id],
75
+ [input.binding_user_id]
54
76
  );
55
77
  chatId = rows[0]?.chat_id;
56
78
  if (!chatId) {
57
79
  throw new SkillExecutionError(
58
80
  "telegram_notify",
59
- `no telegram binding for user ${input.binding_user_id}`,
81
+ `no telegram binding for user ${input.binding_user_id}`
60
82
  );
61
83
  }
62
84
  }
63
85
  const url = `https://api.telegram.org/bot${token}/sendMessage`;
64
- const res = await fetch(url, {
65
- method: "POST",
66
- headers: { "Content-Type": "application/json" },
67
- body: JSON.stringify({
68
- chat_id: chatId,
69
- text: input.text,
70
- parse_mode: input.parse_mode,
71
- }),
72
- });
73
- if (!res.ok) {
74
- throw new SkillExecutionError("telegram_notify", `${res.status}`, {
75
- status: res.status,
76
- });
86
+ try {
87
+ return await retry(
88
+ async () => {
89
+ const res = await fetch(url, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({
93
+ chat_id: chatId,
94
+ text: input.text,
95
+ parse_mode: input.parse_mode,
96
+ }),
97
+ });
98
+ if (!res.ok) {
99
+ const body = await res.text().catch(() => "");
100
+ if (res.status >= 500 || res.status === 429) {
101
+ throw new RetryableHttpError(res.status, body);
102
+ }
103
+ throw new SkillExecutionError(
104
+ "telegram_notify",
105
+ `${res.status}`,
106
+ { status: res.status }
107
+ );
108
+ }
109
+ const data = (await res.json()) as {
110
+ ok?: boolean;
111
+ result?: { message_id?: number };
112
+ };
113
+ return {
114
+ delivered: data.ok === true,
115
+ message_id: data.result?.message_id ?? null,
116
+ };
117
+ },
118
+ { config: TELEGRAM_RETRY }
119
+ );
120
+ } catch (err) {
121
+ const cause =
122
+ err instanceof RetryExhaustedError ? err.lastError : err;
123
+ if (cause instanceof RetryableHttpError) {
124
+ throw new SkillExecutionError(
125
+ "telegram_notify",
126
+ `${cause.status} after retries`,
127
+ { status: cause.status }
128
+ );
129
+ }
130
+ throw cause;
77
131
  }
78
- const data = (await res.json()) as {
79
- ok?: boolean;
80
- result?: { message_id?: number };
81
- };
82
- return {
83
- delivered: data.ok === true,
84
- message_id: data.result?.message_id ?? null,
85
- };
86
132
  });
87
133
  },
88
134
  };