@vauban-org/agent-sdk 1.2.0 → 1.4.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.
- package/CONTRACT.md +595 -7
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/orchestration/ooda/agent.d.ts.map +1 -1
- package/dist/orchestration/ooda/agent.js +77 -0
- package/dist/orchestration/ooda/agent.js.map +1 -1
- package/dist/orchestration/ooda/types.d.ts +11 -0
- package/dist/orchestration/ooda/types.d.ts.map +1 -1
- package/dist/skills/_secrets.d.ts +16 -0
- package/dist/skills/_secrets.d.ts.map +1 -0
- package/dist/skills/_secrets.js +20 -0
- package/dist/skills/_secrets.js.map +1 -0
- package/dist/skills/alpaca-quote.d.ts +2 -2
- package/dist/skills/alpaca-quote.d.ts.map +1 -1
- package/dist/skills/alpaca-quote.js +51 -20
- package/dist/skills/alpaca-quote.js.map +1 -1
- package/dist/skills/send-email.d.ts +2 -2
- package/dist/skills/send-email.d.ts.map +1 -1
- package/dist/skills/send-email.js +1 -12
- package/dist/skills/send-email.js.map +1 -1
- package/dist/skills/slack-notify.d.ts.map +1 -1
- package/dist/skills/slack-notify.js +52 -21
- package/dist/skills/slack-notify.js.map +1 -1
- package/dist/skills/telegram-notify.d.ts.map +1 -1
- package/dist/skills/telegram-notify.js +48 -19
- package/dist/skills/telegram-notify.js.map +1 -1
- package/dist/skills/web-search.d.ts.map +1 -1
- package/dist/skills/web-search.js +85 -40
- package/dist/skills/web-search.js.map +1 -1
- package/dist/telemetry/bus.d.ts +54 -0
- package/dist/telemetry/bus.d.ts.map +1 -0
- package/dist/telemetry/bus.js +159 -0
- package/dist/telemetry/bus.js.map +1 -0
- package/dist/telemetry/index.d.ts +35 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +30 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/telemetry/port.d.ts +121 -0
- package/dist/telemetry/port.d.ts.map +1 -0
- package/dist/telemetry/port.js +48 -0
- package/dist/telemetry/port.js.map +1 -0
- package/dist/telemetry/sinks/otlp.d.ts +45 -0
- package/dist/telemetry/sinks/otlp.d.ts.map +1 -0
- package/dist/telemetry/sinks/otlp.js +195 -0
- package/dist/telemetry/sinks/otlp.js.map +1 -0
- package/dist/telemetry/sinks/sqlite.d.ts +32 -0
- package/dist/telemetry/sinks/sqlite.d.ts.map +1 -0
- package/dist/telemetry/sinks/sqlite.js +170 -0
- package/dist/telemetry/sinks/sqlite.js.map +1 -0
- package/dist/telemetry/sinks/stdout.d.ts +22 -0
- package/dist/telemetry/sinks/stdout.d.ts.map +1 -0
- package/dist/telemetry/sinks/stdout.js +38 -0
- package/dist/telemetry/sinks/stdout.js.map +1 -0
- package/docs/telemetry/migration.md +155 -0
- package/docs/telemetry/overview.md +154 -0
- package/docs/telemetry/privacy.md +127 -0
- package/docs/telemetry/sinks/cc.md +155 -0
- package/docs/telemetry/sinks/otlp.md +146 -0
- package/docs/telemetry/sinks/sqlite.md +126 -0
- package/docs/telemetry/sinks/stdout.md +82 -0
- package/package.json +18 -19
- package/src/index.ts +30 -1
- package/src/orchestration/ooda/agent.ts +105 -0
- package/src/orchestration/ooda/types.ts +12 -0
- package/src/skills/_secrets.ts +25 -0
- package/src/skills/alpaca-quote.ts +68 -23
- package/src/skills/send-email.ts +1 -12
- package/src/skills/slack-notify.ts +73 -30
- package/src/skills/telegram-notify.ts +70 -24
- package/src/skills/web-search.ts +132 -50
- package/src/telemetry/bus.test.ts +231 -0
- package/src/telemetry/bus.ts +241 -0
- package/src/telemetry/index.ts +49 -0
- package/src/telemetry/port.ts +160 -0
- package/src/telemetry/sinks/otlp.test.ts +146 -0
- package/src/telemetry/sinks/otlp.ts +250 -0
- package/src/telemetry/sinks/sqlite.test.ts +121 -0
- package/src/telemetry/sinks/sqlite.ts +260 -0
- package/src/telemetry/sinks/stdout.test.ts +109 -0
- 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,62 @@ 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
|
+
// Accumulators populated by insertStep payloads — sent in finish event.
|
|
322
|
+
const telemetryTotals = {
|
|
323
|
+
inputTokens: 0,
|
|
324
|
+
outputTokens: 0,
|
|
325
|
+
toolCalls: 0,
|
|
326
|
+
costUsd: 0,
|
|
327
|
+
};
|
|
328
|
+
const finishTelemetry = (
|
|
329
|
+
status: "success" | "failed" | "skipped",
|
|
330
|
+
stopReason?: string,
|
|
331
|
+
errorMessage?: string
|
|
332
|
+
): void => {
|
|
333
|
+
if (!telemetry) return;
|
|
334
|
+
void telemetryStartFired.then(() =>
|
|
335
|
+
telemetry
|
|
336
|
+
.finish(runId, {
|
|
337
|
+
status,
|
|
338
|
+
stopReason,
|
|
339
|
+
errorMessage,
|
|
340
|
+
finishedAt: new Date().toISOString(),
|
|
341
|
+
totalInputTokens: telemetryTotals.inputTokens,
|
|
342
|
+
totalOutputTokens: telemetryTotals.outputTokens,
|
|
343
|
+
totalCostUsd: telemetryTotals.costUsd,
|
|
344
|
+
totalToolCalls: telemetryTotals.toolCalls,
|
|
345
|
+
})
|
|
346
|
+
.catch((err: unknown) =>
|
|
347
|
+
logger.warn(
|
|
348
|
+
{ err: (err as Error)?.message ?? String(err) },
|
|
349
|
+
"telemetry.finish.failed"
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
);
|
|
353
|
+
};
|
|
298
354
|
|
|
299
355
|
// ── OTel: agent-level span for the full cycle ───────────────────────────
|
|
300
356
|
const tracer = getTracer("vauban-agent-sdk.ooda");
|
|
@@ -317,6 +373,50 @@ export class OODAAgentImpl<
|
|
|
317
373
|
`OODA: maxStepsPerCycle exceeded (${limits.maxStepsPerCycle})`
|
|
318
374
|
);
|
|
319
375
|
}
|
|
376
|
+
// ADR-ECO-039 — emit telemetry.step alongside the DB write so tokens
|
|
377
|
+
// / cost / phase status surface in the run-level dashboard.
|
|
378
|
+
// Fire-and-forget : DB persistence is source of truth ; telemetry is
|
|
379
|
+
// best-effort.
|
|
380
|
+
if (telemetry) {
|
|
381
|
+
const stepIndex = stepsThisCycle - 1;
|
|
382
|
+
const payload = (input.payload ?? {}) as {
|
|
383
|
+
inputTokens?: number;
|
|
384
|
+
outputTokens?: number;
|
|
385
|
+
costUsd?: number;
|
|
386
|
+
toolCalls?: number;
|
|
387
|
+
durationMs?: number;
|
|
388
|
+
};
|
|
389
|
+
const inT = Number(payload.inputTokens ?? 0);
|
|
390
|
+
const outT = Number(payload.outputTokens ?? 0);
|
|
391
|
+
const cost = Number(payload.costUsd ?? 0);
|
|
392
|
+
const tools = Number(payload.toolCalls ?? 0);
|
|
393
|
+
telemetryTotals.inputTokens += inT;
|
|
394
|
+
telemetryTotals.outputTokens += outT;
|
|
395
|
+
telemetryTotals.costUsd += cost;
|
|
396
|
+
telemetryTotals.toolCalls += tools;
|
|
397
|
+
void telemetryStartFired.then(() =>
|
|
398
|
+
telemetry
|
|
399
|
+
.step(runId, {
|
|
400
|
+
stepIndex,
|
|
401
|
+
kind: input.phase ?? input.type ?? "step",
|
|
402
|
+
status: "completed",
|
|
403
|
+
inputTokens: inT,
|
|
404
|
+
outputTokens: outT,
|
|
405
|
+
toolCalls: tools,
|
|
406
|
+
costUsd: cost,
|
|
407
|
+
durationMs:
|
|
408
|
+
payload.durationMs !== undefined
|
|
409
|
+
? Number(payload.durationMs)
|
|
410
|
+
: undefined,
|
|
411
|
+
})
|
|
412
|
+
.catch((err: unknown) =>
|
|
413
|
+
logger.warn(
|
|
414
|
+
{ err: (err as Error)?.message ?? String(err) },
|
|
415
|
+
"telemetry.step.failed"
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
);
|
|
419
|
+
}
|
|
320
420
|
return baseInsertStep(input);
|
|
321
421
|
};
|
|
322
422
|
|
|
@@ -369,6 +469,7 @@ export class OODAAgentImpl<
|
|
|
369
469
|
rootSpan.end();
|
|
370
470
|
this._cyclesCompleted += 1;
|
|
371
471
|
this._lastCycleAt = new Date().toISOString();
|
|
472
|
+
finishTelemetry("skipped", sessionResult.reason ?? "session_guard");
|
|
372
473
|
return { runId, status: "skipped" };
|
|
373
474
|
}
|
|
374
475
|
|
|
@@ -391,6 +492,7 @@ export class OODAAgentImpl<
|
|
|
391
492
|
rootSpan.end();
|
|
392
493
|
this._cyclesCompleted += 1;
|
|
393
494
|
this._lastCycleAt = new Date().toISOString();
|
|
495
|
+
finishTelemetry("skipped", riskResult.reason ?? "risk_guard");
|
|
394
496
|
return { runId, status: "skipped" };
|
|
395
497
|
}
|
|
396
498
|
|
|
@@ -410,6 +512,7 @@ export class OODAAgentImpl<
|
|
|
410
512
|
rootSpan.end();
|
|
411
513
|
this._cyclesCompleted += 1;
|
|
412
514
|
this._lastCycleAt = new Date().toISOString();
|
|
515
|
+
finishTelemetry("skipped", `heap_exceeded:${heapMb.toFixed(1)}mb`);
|
|
413
516
|
return { runId, status: "skipped" };
|
|
414
517
|
}
|
|
415
518
|
|
|
@@ -489,6 +592,7 @@ export class OODAAgentImpl<
|
|
|
489
592
|
rootSpan.end();
|
|
490
593
|
this._cyclesCompleted += 1;
|
|
491
594
|
this._lastCycleAt = new Date().toISOString();
|
|
595
|
+
finishTelemetry("success");
|
|
492
596
|
return { runId, status: "succeeded" };
|
|
493
597
|
} catch (err) {
|
|
494
598
|
logger.error(
|
|
@@ -509,6 +613,7 @@ export class OODAAgentImpl<
|
|
|
509
613
|
rootSpan.end();
|
|
510
614
|
this._cyclesCompleted += 1;
|
|
511
615
|
this._lastCycleAt = new Date().toISOString();
|
|
616
|
+
finishTelemetry("failed", undefined, (err as Error).message);
|
|
512
617
|
return { runId, status: "failed" };
|
|
513
618
|
}
|
|
514
619
|
}
|
|
@@ -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 =
|
|
50
|
-
const apiSecret =
|
|
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(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
};
|
package/src/skills/send-email.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 =
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
};
|