@alejandroroman/agent-kit 0.1.4 → 0.2.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 (76) hide show
  1. package/dist/_memory/dist/server.js +0 -0
  2. package/dist/_memory/server.js +0 -0
  3. package/dist/agent/loop.js +210 -111
  4. package/dist/api/errors.d.ts +3 -0
  5. package/dist/api/errors.js +37 -0
  6. package/dist/api/events.d.ts +5 -0
  7. package/dist/api/events.js +28 -0
  8. package/dist/api/router.js +10 -0
  9. package/dist/api/traces.d.ts +3 -0
  10. package/dist/api/traces.js +35 -0
  11. package/dist/api/types.d.ts +2 -0
  12. package/dist/bootstrap.d.ts +3 -1
  13. package/dist/bootstrap.js +26 -7
  14. package/dist/cli/chat.js +3 -1
  15. package/dist/cli/claude-md-template.d.ts +5 -0
  16. package/dist/cli/claude-md-template.js +220 -0
  17. package/dist/cli/config-writer.js +3 -0
  18. package/dist/cli/env.d.ts +14 -0
  19. package/dist/cli/env.js +68 -0
  20. package/dist/cli/init.js +10 -0
  21. package/dist/cli/slack-setup.d.ts +6 -0
  22. package/dist/cli/slack-setup.js +234 -0
  23. package/dist/cli/start.js +65 -16
  24. package/dist/cli/ui.d.ts +2 -0
  25. package/dist/cli/ui.js +4 -1
  26. package/dist/cli/whats-new.d.ts +1 -0
  27. package/dist/cli/whats-new.js +69 -0
  28. package/dist/cli.js +14 -0
  29. package/dist/config/resolve.d.ts +1 -0
  30. package/dist/config/resolve.js +1 -0
  31. package/dist/config/schema.d.ts +2 -0
  32. package/dist/config/schema.js +1 -0
  33. package/dist/config/writer.d.ts +18 -0
  34. package/dist/config/writer.js +85 -0
  35. package/dist/cron/scheduler.d.ts +4 -1
  36. package/dist/cron/scheduler.js +99 -52
  37. package/dist/gateways/slack/client.d.ts +1 -0
  38. package/dist/gateways/slack/client.js +9 -0
  39. package/dist/gateways/slack/handler.js +2 -1
  40. package/dist/gateways/slack/index.js +75 -29
  41. package/dist/gateways/slack/listener.d.ts +8 -1
  42. package/dist/gateways/slack/listener.js +36 -10
  43. package/dist/heartbeat/runner.js +99 -82
  44. package/dist/llm/anthropic.d.ts +1 -0
  45. package/dist/llm/anthropic.js +11 -2
  46. package/dist/llm/fallback.js +34 -2
  47. package/dist/llm/openai.d.ts +2 -0
  48. package/dist/llm/openai.js +33 -2
  49. package/dist/llm/types.d.ts +16 -2
  50. package/dist/llm/types.js +9 -0
  51. package/dist/logger.js +8 -0
  52. package/dist/media/sanitize.d.ts +5 -0
  53. package/dist/media/sanitize.js +53 -0
  54. package/dist/multi/spawn.js +29 -10
  55. package/dist/session/compaction.js +3 -1
  56. package/dist/session/prune-images.d.ts +9 -0
  57. package/dist/session/prune-images.js +42 -0
  58. package/dist/skills/activate.d.ts +6 -0
  59. package/dist/skills/activate.js +72 -27
  60. package/dist/skills/index.d.ts +1 -1
  61. package/dist/skills/index.js +1 -1
  62. package/dist/telemetry/db.d.ts +63 -0
  63. package/dist/telemetry/db.js +193 -0
  64. package/dist/telemetry/index.d.ts +17 -0
  65. package/dist/telemetry/index.js +82 -0
  66. package/dist/telemetry/sanitize.d.ts +6 -0
  67. package/dist/telemetry/sanitize.js +48 -0
  68. package/dist/telemetry/sqlite-processor.d.ts +11 -0
  69. package/dist/telemetry/sqlite-processor.js +108 -0
  70. package/dist/telemetry/types.d.ts +30 -0
  71. package/dist/telemetry/types.js +31 -0
  72. package/dist/tools/builtin/index.d.ts +2 -0
  73. package/dist/tools/builtin/index.js +2 -0
  74. package/dist/tools/builtin/self-config.d.ts +4 -0
  75. package/dist/tools/builtin/self-config.js +182 -0
  76. package/package.json +25 -18
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { extractUserText } from "../../llm/types.js";
2
3
  import { markdownToBlocks, markdownToPlainText } from "./format.js";
3
4
  import { BudgetStatusSchema } from "./types.js";
4
5
  import { createLogger } from "../../logger.js";
@@ -16,7 +17,7 @@ function findBudgetToolResults(messages) {
16
17
  const results = [];
17
18
  for (const msg of messages) {
18
19
  if (msg.role === "tool_result" && toolCallNames.get(msg.tool_call_id) === "get_budget_status") {
19
- results.push(msg.content);
20
+ results.push(extractUserText(msg.content));
20
21
  }
21
22
  }
22
23
  return results;
@@ -4,7 +4,25 @@ import { createSlackListener } from "./listener.js";
4
4
  import { createThreadSessionManager } from "./sessions.js";
5
5
  import { markdownToBlocks, markdownToPlainText } from "./format.js";
6
6
  import { createLogger } from "../../logger.js";
7
+ import { context, trace, SpanStatusCode } from "@opentelemetry/api";
8
+ import { getTracer, ATTR } from "../../telemetry/index.js";
7
9
  const log = createLogger("slack");
10
+ function buildUserMessage(msg) {
11
+ if (!msg.files?.length) {
12
+ return { role: "user", content: msg.userText };
13
+ }
14
+ const blocks = [];
15
+ if (msg.userText) {
16
+ blocks.push({ type: "text", text: msg.userText });
17
+ }
18
+ for (const file of msg.files) {
19
+ blocks.push({
20
+ type: "image",
21
+ source: { type: "base64", media_type: file.mimetype, data: file.base64 },
22
+ });
23
+ }
24
+ return { role: "user", content: blocks };
25
+ }
8
26
  export function createSlackGateway(config, options) {
9
27
  const botToken = options?.botToken ?? process.env.SLACK_BOT_TOKEN;
10
28
  const appToken = options?.appToken ?? process.env.SLACK_APP_TOKEN;
@@ -40,37 +58,65 @@ export function createSlackGateway(config, options) {
40
58
  async function processMessage(msg) {
41
59
  if (!executor)
42
60
  return;
43
- const userMsg = { role: "user", content: msg.userText };
44
- sessions.append(msg.threadTs, userMsg);
45
- const messages = sessions.get(msg.threadTs);
46
- try {
47
- const start = Date.now();
48
- log.info({ agent: msg.agentName, thread: msg.threadTs, history: messages.length }, "executing agent");
49
- const result = await executor(msg.agentName, messages);
50
- const elapsed = ((Date.now() - start) / 1000).toFixed(1);
51
- log.info({ agent: msg.agentName, thread: msg.threadTs, elapsed, tokens: result.usage.inputTokens + result.usage.outputTokens }, "agent completed");
52
- // Replace session with full message history from agent
53
- sessions.set(msg.threadTs, result.messages);
54
- const text = result.text || "(completed with no text response)";
55
- const blocks = markdownToBlocks(text);
56
- const fallback = markdownToPlainText(text);
57
- await client.postMessage(msg.channelId, fallback, blocks, msg.threadTs);
58
- log.info({ agent: msg.agentName, thread: msg.threadTs }, "reply posted");
59
- }
60
- catch (err) {
61
- log.error({ err, agent: msg.agentName, thread: msg.threadTs }, "error handling inbound message");
62
- // Roll back user message on failure to avoid malformed conversation history
63
- const current = sessions.get(msg.threadTs);
64
- if (current.length > 0 && current[current.length - 1].role === "user") {
65
- sessions.set(msg.threadTs, current.slice(0, -1));
66
- }
61
+ const tracer = getTracer("slack");
62
+ const span = tracer.startSpan("source.slack", {
63
+ attributes: {
64
+ [ATTR.CHANNEL]: msg.channelId,
65
+ [ATTR.AGENT]: msg.agentName,
66
+ [ATTR.THREAD_TS]: msg.threadTs,
67
+ [ATTR.SOURCE]: "slack",
68
+ },
69
+ });
70
+ const spanCtx = trace.setSpan(context.active(), span);
71
+ await context.with(spanCtx, async () => {
72
+ const userMsg = buildUserMessage(msg);
73
+ sessions.append(msg.threadTs, userMsg);
74
+ const messages = sessions.get(msg.threadTs);
67
75
  try {
68
- await client.postMessage(msg.channelId, "Sorry, I encountered an error processing your message. Please try again in a moment.", undefined, msg.threadTs);
76
+ const start = Date.now();
77
+ log.info({ agent: msg.agentName, thread: msg.threadTs, history: messages.length }, "executing agent");
78
+ const result = await executor(msg.agentName, messages);
79
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
80
+ log.info({ agent: msg.agentName, thread: msg.threadTs, elapsed, tokens: result.usage.inputTokens + result.usage.outputTokens }, "agent completed");
81
+ // Replace session with full message history from agent
82
+ sessions.set(msg.threadTs, result.messages);
83
+ const text = result.text || "(completed with no text response)";
84
+ const blocks = markdownToBlocks(text);
85
+ const fallback = markdownToPlainText(text);
86
+ const postSpan = tracer.startSpan("slack.post", {}, spanCtx);
87
+ try {
88
+ await client.postMessage(msg.channelId, fallback, blocks, msg.threadTs);
89
+ log.info({ agent: msg.agentName, thread: msg.threadTs }, "reply posted");
90
+ }
91
+ catch (postErr) {
92
+ postSpan.setStatus({ code: SpanStatusCode.ERROR, message: String(postErr) });
93
+ postSpan.recordException(postErr instanceof Error ? postErr : new Error(String(postErr)));
94
+ throw postErr;
95
+ }
96
+ finally {
97
+ postSpan.end();
98
+ }
69
99
  }
70
- catch (postErr) {
71
- log.error({ err: postErr, thread: msg.threadTs }, "failed to send error reply");
100
+ catch (err) {
101
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
102
+ span.recordException(err instanceof Error ? err : new Error(String(err)));
103
+ log.error({ err, agent: msg.agentName, thread: msg.threadTs }, "error handling inbound message");
104
+ // Roll back user message on failure to avoid malformed conversation history
105
+ const current = sessions.get(msg.threadTs);
106
+ if (current.length > 0 && current[current.length - 1].role === "user") {
107
+ sessions.set(msg.threadTs, current.slice(0, -1));
108
+ }
109
+ try {
110
+ await client.postMessage(msg.channelId, "Sorry, I encountered an error processing your message. Please try again in a moment.", undefined, msg.threadTs);
111
+ }
112
+ catch (postErr) {
113
+ log.error({ err: postErr, thread: msg.threadTs }, "failed to send error reply");
114
+ }
72
115
  }
73
- }
116
+ finally {
117
+ span.end();
118
+ }
119
+ });
74
120
  }
75
121
  // Eviction timer handle
76
122
  let evictionTimer;
@@ -78,7 +124,7 @@ export function createSlackGateway(config, options) {
78
124
  async start() {
79
125
  await client.start();
80
126
  if (executor) {
81
- createSlackListener(client.app, gatewayConfig, handleInbound);
127
+ createSlackListener(client.app, gatewayConfig, handleInbound, client);
82
128
  // Evict stale threads every hour
83
129
  evictionTimer = setInterval(() => {
84
130
  sessions.evict();
@@ -1,10 +1,17 @@
1
1
  import type { App } from "@slack/bolt";
2
2
  import type { SlackGatewayConfig } from "./types.js";
3
+ import type { SlackClient } from "./client.js";
4
+ export interface InboundFile {
5
+ mimetype: string;
6
+ name: string;
7
+ base64: string;
8
+ }
3
9
  export interface InboundMessage {
4
10
  agentName: string;
5
11
  userText: string;
6
12
  threadTs: string;
7
13
  channelId: string;
14
+ files?: InboundFile[];
8
15
  }
9
16
  export type OnMessageCallback = (message: InboundMessage) => Promise<void>;
10
- export declare function createSlackListener(app: App, config: SlackGatewayConfig, onMessage: OnMessageCallback): void;
17
+ export declare function createSlackListener(app: App, config: SlackGatewayConfig, onMessage: OnMessageCallback, client?: SlackClient): void;
@@ -1,33 +1,59 @@
1
+ import { sanitizeImage } from "../../media/sanitize.js";
1
2
  import { createLogger } from "../../logger.js";
2
3
  const log = createLogger("slack:listener");
3
- export function createSlackListener(app, config, onMessage) {
4
- // Build reverse map: channelId agentName
4
+ const IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
5
+ export function createSlackListener(app, config, onMessage, client) {
5
6
  const channelToAgent = new Map();
6
7
  for (const [agentName, binding] of Object.entries(config.channels)) {
7
8
  channelToAgent.set(binding.channelId, agentName);
8
9
  }
9
10
  app.event("message", async (payload) => {
10
- // Handle both Bolt's { event } wrapper and direct event (tests)
11
11
  const msg = payload.event ?? payload;
12
12
  // Filter out bot messages and subtypes (edits, deletes, etc.)
13
- if (msg.bot_id || msg.subtype)
13
+ // Allow file_share through so we can process image attachments
14
+ if (msg.bot_id)
14
15
  return;
15
- if (!msg.text || typeof msg.text !== "string")
16
+ if (msg.subtype && msg.subtype !== "file_share")
17
+ return;
18
+ const hasText = msg.text && typeof msg.text === "string";
19
+ const hasFiles = Array.isArray(msg.files) && msg.files.length > 0;
20
+ if (!hasText && !hasFiles)
16
21
  return;
17
22
  const channelId = msg.channel;
18
23
  const agentName = channelToAgent.get(channelId);
19
24
  if (!agentName)
20
25
  return;
21
- // Use thread_ts if in a thread, otherwise use ts as the thread parent
22
26
  const threadTs = msg.thread_ts ?? msg.ts;
23
- // Fire-and-forget: don't await the handler so the Bolt event pipeline
24
- // returns immediately. This prevents long-running agent loops from
25
- // stalling WebSocket ping/pong and causing socket timeouts.
27
+ let files;
28
+ if (hasFiles && client) {
29
+ files = [];
30
+ for (const file of msg.files) {
31
+ if (!IMAGE_MIMES.has(file.mimetype)) {
32
+ log.debug({ name: file.name, mimetype: file.mimetype }, "skipping non-image file");
33
+ continue;
34
+ }
35
+ try {
36
+ const buffer = await client.downloadFile(file.url_private_download);
37
+ const sanitized = await sanitizeImage(buffer, file.mimetype);
38
+ files.push({
39
+ mimetype: sanitized.mediaType,
40
+ name: file.name ?? "image",
41
+ base64: sanitized.base64,
42
+ });
43
+ }
44
+ catch (err) {
45
+ log.warn({ err, name: file.name }, "failed to download/sanitize image");
46
+ }
47
+ }
48
+ if (files.length === 0)
49
+ files = undefined;
50
+ }
26
51
  onMessage({
27
52
  agentName,
28
- userText: msg.text,
53
+ userText: msg.text ?? "",
29
54
  threadTs,
30
55
  channelId,
56
+ files,
31
57
  }).catch((err) => {
32
58
  log.error({ err, channelId }, "unhandled error processing message");
33
59
  });
@@ -5,9 +5,11 @@ import { setupAgentSession } from "../agent/setup.js";
5
5
  import { resolveAgent, resolveModelAlias, resolveWebSearch } from "../config/resolve.js";
6
6
  import { createBuiltinRegistry } from "../tools/builtin/index.js";
7
7
  import { registerSpawnWrappers } from "../tools/builtin/spawn.js";
8
- import { createActivateSkillTool } from "../skills/index.js";
8
+ import { createActivateSkillTool, preActivateSkills } from "../skills/index.js";
9
9
  import { createLogger } from "../logger.js";
10
10
  import { dateContext } from "../text.js";
11
+ import { context, trace, SpanStatusCode } from "@opentelemetry/api";
12
+ import { getTracer, ATTR } from "../telemetry/index.js";
11
13
  const log = createLogger("heartbeat");
12
14
  const HEARTBEAT_OK = "HEARTBEAT_OK";
13
15
  export function isHeartbeatSuppressed(text) {
@@ -64,93 +66,99 @@ export class HeartbeatRunner {
64
66
  if (!agentDef?.heartbeat)
65
67
  return undefined;
66
68
  const hb = agentDef.heartbeat;
67
- if (!isWithinActiveHours(hb.activeHours)) {
68
- log.debug({ agent: agentName }, "outside active hours, skipping");
69
- return undefined;
70
- }
71
- const instructions = this.loadHeartbeatInstructions(agentName);
72
- if (!instructions) {
73
- log.warn({ agent: agentName }, "no HEARTBEAT.md found, skipping");
74
- return undefined;
75
- }
76
- // Isolated tool registry per tick (same pattern as CronScheduler)
77
- const sandbox = agentDef.sandbox ?? this.config.defaults.sandbox;
78
- const memoryConfig = this.config.defaults.memory;
79
- const tickRegistry = createBuiltinRegistry({
80
- allowedCommands: sandbox?.allowedCommands,
81
- allowedPaths: sandbox?.allowedPaths,
82
- memoryConfig,
83
- webSearch: resolveWebSearch(agentName, this.config),
69
+ const tracer = getTracer("heartbeat");
70
+ const span = tracer.startSpan("source.heartbeat", {
71
+ attributes: {
72
+ [ATTR.AGENT]: agentName,
73
+ [ATTR.SOURCE]: "heartbeat",
74
+ },
84
75
  });
85
- const resolved = resolveAgent(agentName, this.config, tickRegistry, this.skillsDir);
86
- // Skills setup — auto-activate all skills upfront so Haiku doesn't need to call activate_skill
87
- const promptFragments = [];
88
- let skillsIndex = "";
89
- if (resolved.skills.length > 0) {
90
- const ctx = {
91
- manifests: resolved.skills,
92
- skillsDir: this.skillsDir,
93
- toolRegistry: tickRegistry,
94
- promptFragments,
95
- activatedSkills: new Set(),
96
- };
97
- const activateTool = createActivateSkillTool(ctx);
98
- tickRegistry.register(activateTool);
99
- // Auto-activate all skills upfront
100
- for (const manifest of resolved.skills) {
101
- try {
102
- const result = await activateTool.execute({ skill_name: manifest.name });
103
- if (typeof result === "string" && result.startsWith("Error")) {
104
- log.error({ agent: agentName, skill: manifest.name, result }, "failed to auto-activate skill");
105
- }
106
- }
107
- catch (err) {
108
- log.error({ err, agent: agentName, skill: manifest.name }, "skill auto-activation threw");
109
- }
76
+ const spanCtx = trace.setSpan(context.active(), span);
77
+ try {
78
+ if (!isWithinActiveHours(hb.activeHours)) {
79
+ log.debug({ agent: agentName }, "outside active hours, skipping");
80
+ span.setAttribute(ATTR.SUPPRESSED, "outside_active_hours");
81
+ return undefined;
110
82
  }
111
- const activatedNames = [...ctx.activatedSkills];
112
- if (activatedNames.length > 0) {
113
- skillsIndex = "\n\nThe following skills are pre-activated: "
114
- + activatedNames.join(", ")
115
- + ".\nTheir tools are already available — you do not need to call activate_skill.";
83
+ const instructions = this.loadHeartbeatInstructions(agentName);
84
+ if (!instructions) {
85
+ log.warn({ agent: agentName }, "no HEARTBEAT.md found, skipping");
86
+ span.setAttribute(ATTR.SUPPRESSED, "no_instructions");
87
+ return undefined;
116
88
  }
89
+ return await context.with(spanCtx, async () => {
90
+ // Isolated tool registry per tick (same pattern as CronScheduler)
91
+ const sandbox = agentDef.sandbox ?? this.config.defaults.sandbox;
92
+ const memoryConfig = this.config.defaults.memory;
93
+ const tickRegistry = createBuiltinRegistry({
94
+ allowedCommands: sandbox?.allowedCommands,
95
+ allowedPaths: sandbox?.allowedPaths,
96
+ memoryConfig,
97
+ webSearch: resolveWebSearch(agentName, this.config),
98
+ });
99
+ const resolved = resolveAgent(agentName, this.config, tickRegistry, this.skillsDir);
100
+ // Skills setup — auto-activate all skills upfront so Haiku doesn't need to call activate_skill
101
+ const promptFragments = [];
102
+ let skillsIndex = "";
103
+ if (resolved.skills.length > 0) {
104
+ const ctx = {
105
+ manifests: resolved.skills,
106
+ skillsDir: this.skillsDir,
107
+ toolRegistry: tickRegistry,
108
+ promptFragments,
109
+ activatedSkills: new Set(),
110
+ };
111
+ const activateTool = createActivateSkillTool(ctx);
112
+ tickRegistry.register(activateTool);
113
+ // Heartbeat always auto-activates (Haiku can't reliably call activate_skill)
114
+ skillsIndex = await preActivateSkills(ctx, activateTool, log);
115
+ }
116
+ // Spawn wrappers
117
+ if (resolved.canSpawn.length > 0) {
118
+ registerSpawnWrappers(resolved.canSpawn, this.config, this.agentRegistry, tickRegistry, this.usageStore);
119
+ }
120
+ // Ephemeral session: no history accumulation, append-only audit log
121
+ const sessionId = `heartbeat-${agentName}`;
122
+ const { soul, session } = setupAgentSession(this.dataDir, agentName, sessionId);
123
+ // Build tick prompt with fresh instructions + timestamp
124
+ const tickPrompt = `[Heartbeat tick — ${new Date().toISOString()}]\n\n${instructions}`;
125
+ const userMsg = { role: "user", content: tickPrompt };
126
+ session.append(userMsg); // audit only
127
+ const messages = [userMsg]; // ephemeral — no history
128
+ // Model: heartbeat.model overrides agent model
129
+ const model = hb.model
130
+ ? resolveModelAlias(hb.model, this.config.models)
131
+ : resolved.model;
132
+ const systemPrompt = [soul, dateContext(), skillsIndex, ...promptFragments]
133
+ .filter(Boolean)
134
+ .join("\n\n") || undefined;
135
+ const result = await runAgentLoop(messages, {
136
+ model,
137
+ fallbacks: resolved.fallbacks,
138
+ systemPrompt,
139
+ toolRegistry: tickRegistry,
140
+ maxIterations: hb.maxIterations ?? resolved.maxIterations,
141
+ compactionThreshold: resolved.compactionThreshold,
142
+ maxToolResultSize: resolved.maxToolResultSize,
143
+ agentName,
144
+ usageStore: this.usageStore,
145
+ source: "heartbeat",
146
+ });
147
+ // Audit: append all new messages from the agent loop
148
+ for (const msg of result.messages.slice(1)) {
149
+ session.append(msg);
150
+ }
151
+ return result;
152
+ });
117
153
  }
118
- // Spawn wrappers
119
- if (resolved.canSpawn.length > 0) {
120
- registerSpawnWrappers(resolved.canSpawn, this.config, this.agentRegistry, tickRegistry, this.usageStore);
154
+ catch (err) {
155
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
156
+ span.recordException(err instanceof Error ? err : new Error(String(err)));
157
+ throw err;
121
158
  }
122
- // Ephemeral session: no history accumulation, append-only audit log
123
- const sessionId = `heartbeat-${agentName}`;
124
- const { soul, session } = setupAgentSession(this.dataDir, agentName, sessionId);
125
- // Build tick prompt with fresh instructions + timestamp
126
- const tickPrompt = `[Heartbeat tick — ${new Date().toISOString()}]\n\n${instructions}`;
127
- const userMsg = { role: "user", content: tickPrompt };
128
- session.append(userMsg); // audit only
129
- const messages = [userMsg]; // ephemeral — no history
130
- // Model: heartbeat.model overrides agent model
131
- const model = hb.model
132
- ? resolveModelAlias(hb.model, this.config.models)
133
- : resolved.model;
134
- const systemPrompt = [soul, dateContext(), skillsIndex, ...promptFragments]
135
- .filter(Boolean)
136
- .join("\n\n") || undefined;
137
- const result = await runAgentLoop(messages, {
138
- model,
139
- fallbacks: resolved.fallbacks,
140
- systemPrompt,
141
- toolRegistry: tickRegistry,
142
- maxIterations: hb.maxIterations ?? resolved.maxIterations,
143
- compactionThreshold: resolved.compactionThreshold,
144
- maxToolResultSize: resolved.maxToolResultSize,
145
- agentName,
146
- usageStore: this.usageStore,
147
- source: "heartbeat",
148
- });
149
- // Audit: append all new messages from the agent loop
150
- for (const msg of result.messages.slice(1)) {
151
- session.append(msg);
159
+ finally {
160
+ span.end();
152
161
  }
153
- return result;
154
162
  }
155
163
  start(callbacks) {
156
164
  for (const [agentName, agentDef] of Object.entries(this.config.agents)) {
@@ -162,6 +170,15 @@ export class HeartbeatRunner {
162
170
  const runTick = async () => {
163
171
  if (this.running.has(agentName)) {
164
172
  log.info({ agent: agentName }, "previous tick still running, skipping");
173
+ const tracer = getTracer("heartbeat");
174
+ const skipSpan = tracer.startSpan("source.heartbeat", {
175
+ attributes: {
176
+ [ATTR.AGENT]: agentName,
177
+ [ATTR.SOURCE]: "heartbeat",
178
+ [ATTR.SUPPRESSED]: "already_running",
179
+ },
180
+ });
181
+ skipSpan.end();
165
182
  return;
166
183
  }
167
184
  this.running.add(agentName);
@@ -7,6 +7,7 @@ export declare class AnthropicProvider implements LLMProvider {
7
7
  complete(model: string, messages: Message[], options?: CompleteOptions): Promise<LLMResponse>;
8
8
  stream(model: string, messages: Message[], options?: CompleteOptions): AsyncIterable<StreamEvent>;
9
9
  private toAnthropicMessages;
10
+ private mapUserContent;
10
11
  private fromAnthropicResponse;
11
12
  private mapStopReason;
12
13
  }
@@ -39,7 +39,7 @@ export class AnthropicProvider {
39
39
  toAnthropicMessages(messages) {
40
40
  return messages.map((msg) => {
41
41
  if (msg.role === "user")
42
- return { role: "user", content: msg.content };
42
+ return { role: "user", content: this.mapUserContent(msg.content) };
43
43
  if (msg.role === "assistant") {
44
44
  return {
45
45
  role: "assistant",
@@ -51,11 +51,20 @@ export class AnthropicProvider {
51
51
  };
52
52
  }
53
53
  if (msg.role === "tool_result") {
54
- return { role: "user", content: [{ type: "tool_result", tool_use_id: msg.tool_call_id, content: msg.content }] };
54
+ return { role: "user", content: [{ type: "tool_result", tool_use_id: msg.tool_call_id, content: this.mapUserContent(msg.content) }] };
55
55
  }
56
56
  return msg;
57
57
  });
58
58
  }
59
+ mapUserContent(content) {
60
+ if (typeof content === "string")
61
+ return content;
62
+ return content.map((block) => {
63
+ if (block.type === "text")
64
+ return { type: "text", text: block.text };
65
+ return { type: "image", source: block.source };
66
+ });
67
+ }
59
68
  fromAnthropicResponse(response) {
60
69
  const content = response.content.map((block) => {
61
70
  if (block.type === "text")
@@ -1,4 +1,6 @@
1
1
  import { createLogger } from "../logger.js";
2
+ import { getTracer, ATTR } from "../telemetry/index.js";
3
+ import { SpanStatusCode, context } from "@opentelemetry/api";
2
4
  const log = createLogger("llm");
3
5
  const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503]);
4
6
  export function isRetryableError(err) {
@@ -12,17 +14,47 @@ export function isRetryableError(err) {
12
14
  return false;
13
15
  }
14
16
  export async function completeWithFallback(primary, fallbacks, messages, options, completeFn) {
17
+ const tracer = getTracer("llm");
15
18
  const models = [primary, ...fallbacks];
16
19
  let lastError;
17
- for (const model of models) {
20
+ for (let idx = 0; idx < models.length; idx++) {
21
+ const model = models[idx];
22
+ const [provider = "unknown"] = model.split(":");
23
+ const attemptSpan = tracer.startSpan("llm.attempt", {}, context.active());
24
+ attemptSpan.setAttribute(ATTR.PROVIDER, provider);
25
+ attemptSpan.setAttribute(ATTR.MODEL, model);
18
26
  try {
19
- return await completeFn(model, messages, options);
27
+ const response = await completeFn(model, messages, options);
28
+ // Set token attributes on successful attempt
29
+ attemptSpan.setAttribute(ATTR.INPUT_TOKENS, response.usage.inputTokens);
30
+ attemptSpan.setAttribute(ATTR.OUTPUT_TOKENS, response.usage.outputTokens);
31
+ attemptSpan.setAttribute(ATTR.CACHE_CREATION_TOKENS, response.usage.cacheCreationTokens);
32
+ attemptSpan.setAttribute(ATTR.CACHE_READ_TOKENS, response.usage.cacheReadTokens);
33
+ attemptSpan.setAttribute(ATTR.STOP_REASON, response.stopReason);
34
+ if (response.latencyMs != null) {
35
+ attemptSpan.setAttribute(ATTR.LATENCY_MS, response.latencyMs);
36
+ }
37
+ attemptSpan.end();
38
+ return response;
20
39
  }
21
40
  catch (err) {
22
41
  lastError = err;
42
+ const errMsg = err instanceof Error ? err.message : String(err);
43
+ attemptSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
44
+ if (err instanceof Error)
45
+ attemptSpan.recordException(err);
46
+ attemptSpan.end();
23
47
  if (!isRetryableError(err) || model === models[models.length - 1]) {
24
48
  throw err;
25
49
  }
50
+ // Record fallback span for the transition to the next model
51
+ const nextModel = models[idx + 1];
52
+ const fallbackSpan = tracer.startSpan("llm.fallback", {}, context.active());
53
+ fallbackSpan.setAttribute(ATTR.FALLBACK_ORIGINAL, model);
54
+ fallbackSpan.setAttribute(ATTR.FALLBACK_MODEL, nextModel);
55
+ fallbackSpan.setAttribute(ATTR.FALLBACK_ERROR, errMsg);
56
+ fallbackSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
57
+ fallbackSpan.end();
26
58
  log.warn({ err, model }, "model failed, trying next");
27
59
  }
28
60
  }
@@ -7,6 +7,8 @@ export declare class OpenAIProvider implements LLMProvider {
7
7
  complete(model: string, messages: Message[], options?: CompleteOptions): Promise<LLMResponse>;
8
8
  stream(model: string, messages: Message[], options?: CompleteOptions): AsyncIterable<StreamEvent>;
9
9
  private toOpenAIMessages;
10
+ private mapImageBlock;
11
+ private mapUserContentToOpenAI;
10
12
  private fromOpenAIResponse;
11
13
  private mapStopReason;
12
14
  }
@@ -30,7 +30,7 @@ export class OpenAIProvider {
30
30
  result.push({ role: "system", content: systemPrompt });
31
31
  for (const msg of messages) {
32
32
  if (msg.role === "user") {
33
- result.push({ role: "user", content: msg.content });
33
+ result.push({ role: "user", content: this.mapUserContentToOpenAI(msg.content) });
34
34
  }
35
35
  else if (msg.role === "assistant") {
36
36
  const textParts = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("");
@@ -45,11 +45,42 @@ export class OpenAIProvider {
45
45
  result.push(openaiMsg);
46
46
  }
47
47
  else if (msg.role === "tool_result") {
48
- result.push({ role: "tool", tool_call_id: msg.tool_call_id, content: msg.content });
48
+ if (typeof msg.content === "string") {
49
+ result.push({ role: "tool", tool_call_id: msg.tool_call_id, content: msg.content });
50
+ }
51
+ else {
52
+ const textParts = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("");
53
+ const imageBlocks = msg.content.filter((b) => b.type === "image");
54
+ result.push({ role: "tool", tool_call_id: msg.tool_call_id, content: textParts });
55
+ if (imageBlocks.length) {
56
+ result.push({
57
+ role: "user",
58
+ content: [
59
+ { type: "text", text: "Image(s) from tool result:" },
60
+ ...imageBlocks.map((b) => this.mapImageBlock(b)),
61
+ ],
62
+ });
63
+ }
64
+ }
49
65
  }
50
66
  }
51
67
  return result;
52
68
  }
69
+ mapImageBlock(block) {
70
+ if (block.source.type === "base64") {
71
+ return { type: "image_url", image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` } };
72
+ }
73
+ return { type: "image_url", image_url: { url: block.source.url } };
74
+ }
75
+ mapUserContentToOpenAI(content) {
76
+ if (typeof content === "string")
77
+ return content;
78
+ return content.map((block) => {
79
+ if (block.type === "text")
80
+ return { type: "text", text: block.text };
81
+ return this.mapImageBlock(block);
82
+ });
83
+ }
53
84
  fromOpenAIResponse(response) {
54
85
  const choice = response.choices[0];
55
86
  const content = [];
@@ -8,10 +8,22 @@ export interface ToolCallBlock {
8
8
  name: string;
9
9
  arguments: Record<string, unknown>;
10
10
  }
11
+ export interface ImageBlock {
12
+ type: "image";
13
+ source: {
14
+ type: "base64";
15
+ media_type: string;
16
+ data: string;
17
+ } | {
18
+ type: "url";
19
+ url: string;
20
+ };
21
+ }
22
+ export type UserContentBlock = TextBlock | ImageBlock;
11
23
  export type ContentBlock = TextBlock | ToolCallBlock;
12
24
  export interface UserMessage {
13
25
  role: "user";
14
- content: string;
26
+ content: string | UserContentBlock[];
15
27
  }
16
28
  export interface AssistantMessage {
17
29
  role: "assistant";
@@ -20,7 +32,7 @@ export interface AssistantMessage {
20
32
  export interface ToolResultMessage {
21
33
  role: "tool_result";
22
34
  tool_call_id: string;
23
- content: string;
35
+ content: string | UserContentBlock[];
24
36
  }
25
37
  export type Message = UserMessage | AssistantMessage | ToolResultMessage;
26
38
  export interface ToolDefinition {
@@ -71,3 +83,5 @@ export interface ErrorEvent {
71
83
  }
72
84
  export type StreamEvent = TextDeltaEvent | ToolCallStartEvent | ToolCallDeltaEvent | DoneEvent | ErrorEvent;
73
85
  export declare function extractText(content: ContentBlock[]): string;
86
+ /** Extract text from user content (string or UserContentBlock[]) */
87
+ export declare function extractUserText(content: string | UserContentBlock[]): string;