@agentforge-io/core 2.0.4 → 2.0.6

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.
@@ -0,0 +1,21 @@
1
+ import type { AgentToolDefinition } from '../types/agent.types';
2
+ /**
3
+ * `current_time` — built-in tool every agent gets for free.
4
+ *
5
+ * Models can't read the system clock. Without this, "what's today's
6
+ * date?" / "what time is it in Bogotá?" answers drift to either the
7
+ * training cutoff (months off) or the host server's UTC, neither of
8
+ * which match the user's expectations. This tool anchors every time
9
+ * question to the agent's configured IANA timezone.
10
+ *
11
+ * Input is optional: when no `timezone` argument is supplied we fall
12
+ * back to `context.agent.timezone` (set by the runner from the
13
+ * AgentDefinition), and finally to `"UTC"` so the call still succeeds
14
+ * on agents with no tz configured.
15
+ *
16
+ * Output is a compact JSON object — easier for the model to quote
17
+ * specific pieces (just the weekday, just the ISO) than a natural-
18
+ * language string would be.
19
+ */
20
+ export declare const CURRENT_TIME_TOOL_NAME = "current_time";
21
+ export declare function currentTimeTool(): AgentToolDefinition;
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CURRENT_TIME_TOOL_NAME = void 0;
4
+ exports.currentTimeTool = currentTimeTool;
5
+ /**
6
+ * `current_time` — built-in tool every agent gets for free.
7
+ *
8
+ * Models can't read the system clock. Without this, "what's today's
9
+ * date?" / "what time is it in Bogotá?" answers drift to either the
10
+ * training cutoff (months off) or the host server's UTC, neither of
11
+ * which match the user's expectations. This tool anchors every time
12
+ * question to the agent's configured IANA timezone.
13
+ *
14
+ * Input is optional: when no `timezone` argument is supplied we fall
15
+ * back to `context.agent.timezone` (set by the runner from the
16
+ * AgentDefinition), and finally to `"UTC"` so the call still succeeds
17
+ * on agents with no tz configured.
18
+ *
19
+ * Output is a compact JSON object — easier for the model to quote
20
+ * specific pieces (just the weekday, just the ISO) than a natural-
21
+ * language string would be.
22
+ */
23
+ exports.CURRENT_TIME_TOOL_NAME = 'current_time';
24
+ function currentTimeTool() {
25
+ return {
26
+ name: exports.CURRENT_TIME_TOOL_NAME,
27
+ alwaysOn: true,
28
+ description: "Get the current date and time. Use this any time the user asks " +
29
+ 'about "now", "today", "the current date", a deadline relative to ' +
30
+ "today, or anything else that depends on knowing the real-world " +
31
+ "wall-clock. The result is anchored to the agent's configured " +
32
+ 'timezone unless the caller passes an explicit `timezone` argument.',
33
+ inputSchema: {
34
+ type: 'object',
35
+ properties: {
36
+ timezone: {
37
+ type: 'string',
38
+ description: 'Optional IANA timezone (e.g. "America/Bogota", "Europe/Madrid", ' +
39
+ '"Asia/Tokyo"). Defaults to the agent\'s configured timezone.',
40
+ },
41
+ },
42
+ additionalProperties: false,
43
+ },
44
+ execute: async (input, context) => {
45
+ const requested = typeof input.timezone === 'string' && input.timezone.trim()
46
+ ? input.timezone.trim()
47
+ : undefined;
48
+ const fallback = context.agent?.timezone || 'UTC';
49
+ const tz = requested ?? fallback;
50
+ const now = new Date();
51
+ let iso;
52
+ let weekday;
53
+ let resolvedTz = tz;
54
+ try {
55
+ // Use Intl to format the offset in the target tz, then assemble an
56
+ // ISO 8601 string that includes the offset. `toISOString()` is UTC
57
+ // only, which would defeat the purpose.
58
+ iso = formatIsoInTimezone(now, tz);
59
+ weekday = new Intl.DateTimeFormat('en-US', {
60
+ weekday: 'long',
61
+ timeZone: tz,
62
+ }).format(now);
63
+ }
64
+ catch {
65
+ // Invalid IANA string — fall back to UTC rather than failing the
66
+ // tool call (which would surface as a confusing model error).
67
+ resolvedTz = 'UTC';
68
+ iso = now.toISOString();
69
+ weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(now);
70
+ }
71
+ return JSON.stringify({
72
+ iso,
73
+ timezone: resolvedTz,
74
+ weekday,
75
+ epochMs: now.getTime(),
76
+ ...(requested && requested !== resolvedTz
77
+ ? { requestedTimezone: requested, note: 'invalid_timezone_fell_back_to_utc' }
78
+ : {}),
79
+ });
80
+ },
81
+ };
82
+ }
83
+ /**
84
+ * Build an ISO 8601 string that reflects the local wall-clock in `tz`.
85
+ *
86
+ * `Date#toISOString()` always emits UTC ("…Z"), so we hand-compose the
87
+ * parts via Intl. The shape is `YYYY-MM-DDTHH:mm:ss±HH:MM`, the form the
88
+ * model is most likely to have seen in training data.
89
+ */
90
+ function formatIsoInTimezone(date, tz) {
91
+ // Throws on invalid tz — surfaced as a try/catch in the caller.
92
+ const dtf = new Intl.DateTimeFormat('en-US', {
93
+ timeZone: tz,
94
+ year: 'numeric',
95
+ month: '2-digit',
96
+ day: '2-digit',
97
+ hour: '2-digit',
98
+ minute: '2-digit',
99
+ second: '2-digit',
100
+ hour12: false,
101
+ });
102
+ const parts = Object.fromEntries(dtf.formatToParts(date).map((p) => [p.type, p.value]));
103
+ const offset = formatOffset(date, tz);
104
+ // `hour` reports "24" at midnight on some engines — normalize.
105
+ const hour = parts.hour === '24' ? '00' : parts.hour;
106
+ return `${parts.year}-${parts.month}-${parts.day}T${hour}:${parts.minute}:${parts.second}${offset}`;
107
+ }
108
+ function formatOffset(date, tz) {
109
+ // Compute the offset by formatting the same instant in UTC vs target tz
110
+ // and taking the delta. Cheaper than parsing Intl's `shortOffset` token,
111
+ // and avoids engine-specific formatting quirks.
112
+ const tzParts = partsToUtcMs(date, tz);
113
+ const utcParts = partsToUtcMs(date, 'UTC');
114
+ const diffMin = Math.round((tzParts - utcParts) / 60000);
115
+ const sign = diffMin >= 0 ? '+' : '-';
116
+ const abs = Math.abs(diffMin);
117
+ const hh = String(Math.floor(abs / 60)).padStart(2, '0');
118
+ const mm = String(abs % 60).padStart(2, '0');
119
+ return `${sign}${hh}:${mm}`;
120
+ }
121
+ function partsToUtcMs(date, tz) {
122
+ const dtf = new Intl.DateTimeFormat('en-US', {
123
+ timeZone: tz,
124
+ year: 'numeric',
125
+ month: '2-digit',
126
+ day: '2-digit',
127
+ hour: '2-digit',
128
+ minute: '2-digit',
129
+ second: '2-digit',
130
+ hour12: false,
131
+ });
132
+ const p = Object.fromEntries(dtf.formatToParts(date).map((pp) => [pp.type, pp.value]));
133
+ const hour = p.hour === '24' ? '00' : p.hour;
134
+ return Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(hour), Number(p.minute), Number(p.second));
135
+ }
@@ -0,0 +1 @@
1
+ export { currentTimeTool, CURRENT_TIME_TOOL_NAME } from './current-time.tool';
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ // Built-in tools — always available to every agent regardless of the
3
+ // `agent.tools[]` whitelist. The runner registers these alongside the
4
+ // host-supplied catalog at boot time. UIs can still show them in the
5
+ // picker (read-only) so users understand the agent can call them.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.CURRENT_TIME_TOOL_NAME = exports.currentTimeTool = void 0;
8
+ var current_time_tool_1 = require("./current-time.tool");
9
+ Object.defineProperty(exports, "currentTimeTool", { enumerable: true, get: function () { return current_time_tool_1.currentTimeTool; } });
10
+ Object.defineProperty(exports, "CURRENT_TIME_TOOL_NAME", { enumerable: true, get: function () { return current_time_tool_1.CURRENT_TIME_TOOL_NAME; } });
@@ -17,6 +17,27 @@ export declare class AgentRunnerService {
17
17
  });
18
18
  run(agent: AgentDefinition, messages: AnthropicMessage[], context: ToolExecutionContext, overrides?: AgentOverrides): Promise<AgentResponse>;
19
19
  stream(agent: AgentDefinition, messages: AnthropicMessage[], context: ToolExecutionContext, overrides?: AgentOverrides): AsyncGenerator<StreamChunk>;
20
+ /**
21
+ * Compose the system prompt for a single Anthropic call:
22
+ *
23
+ * [tool-awareness preamble] ← only when `tools` is non-empty
24
+ * ──────────────────────────
25
+ * agent.systemPrompt ← what the operator wrote
26
+ * ──────────────────────────
27
+ * overrides.systemPromptSuffix ← optional per-call addendum
28
+ *
29
+ * The preamble exists because operator prompts in the wild often pin
30
+ * the model to a narrow persona ("respond in three sentences", "stick
31
+ * to product Q&A") which suppresses tool use even when tools are
32
+ * attached and clearly relevant. The preamble doesn't override the
33
+ * operator — it's a short, generic reminder that listed tools are
34
+ * available and should be consulted when the user's question depends
35
+ * on real-world / current / authoritative information.
36
+ *
37
+ * It's only added when tools are present, so prompt-only agents stay
38
+ * exactly as the operator wrote them.
39
+ */
40
+ private buildSystemPrompt;
20
41
  /**
21
42
  * Merge `agent.tools[]` (resolved via the global registry) with any
22
43
  * per-call extras (e.g. the user's connector tools).
@@ -30,10 +30,8 @@ class AgentRunnerService {
30
30
  const model = overrides?.model ?? agent.model ?? this.anthropicConfig.defaultModel ?? 'claude-opus-4-6';
31
31
  const maxTokens = overrides?.maxTokens ?? agent.maxTokens ?? this.anthropicConfig.defaultMaxTokens ?? 4096;
32
32
  const temperature = overrides?.temperature ?? agent.temperature ?? 1;
33
- const systemPrompt = overrides?.systemPromptSuffix
34
- ? `${agent.systemPrompt}\n\n${overrides.systemPromptSuffix}`
35
- : agent.systemPrompt;
36
33
  const { tools, extras } = this.buildToolList(agent, overrides);
34
+ const systemPrompt = this.buildSystemPrompt(agent, tools, overrides);
37
35
  const toolCalls = [];
38
36
  let currentMessages = [...messages];
39
37
  let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -117,10 +115,8 @@ class AgentRunnerService {
117
115
  const model = overrides?.model ?? agent.model ?? this.anthropicConfig.defaultModel ?? 'claude-opus-4-6';
118
116
  const maxTokens = overrides?.maxTokens ?? agent.maxTokens ?? this.anthropicConfig.defaultMaxTokens ?? 4096;
119
117
  const temperature = overrides?.temperature ?? agent.temperature ?? 1;
120
- const systemPrompt = overrides?.systemPromptSuffix
121
- ? `${agent.systemPrompt}\n\n${overrides.systemPromptSuffix}`
122
- : agent.systemPrompt;
123
118
  const { tools, extras } = this.buildToolList(agent, overrides);
119
+ const systemPrompt = this.buildSystemPrompt(agent, tools, overrides);
124
120
  let currentMessages = [...messages];
125
121
  let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
126
122
  while (true) {
@@ -184,6 +180,44 @@ class AgentRunnerService {
184
180
  yield { type: 'usage', usage: totalUsage };
185
181
  yield { type: 'done', messageId };
186
182
  }
183
+ /**
184
+ * Compose the system prompt for a single Anthropic call:
185
+ *
186
+ * [tool-awareness preamble] ← only when `tools` is non-empty
187
+ * ──────────────────────────
188
+ * agent.systemPrompt ← what the operator wrote
189
+ * ──────────────────────────
190
+ * overrides.systemPromptSuffix ← optional per-call addendum
191
+ *
192
+ * The preamble exists because operator prompts in the wild often pin
193
+ * the model to a narrow persona ("respond in three sentences", "stick
194
+ * to product Q&A") which suppresses tool use even when tools are
195
+ * attached and clearly relevant. The preamble doesn't override the
196
+ * operator — it's a short, generic reminder that listed tools are
197
+ * available and should be consulted when the user's question depends
198
+ * on real-world / current / authoritative information.
199
+ *
200
+ * It's only added when tools are present, so prompt-only agents stay
201
+ * exactly as the operator wrote them.
202
+ */
203
+ buildSystemPrompt(agent, tools, overrides) {
204
+ const parts = [];
205
+ if (tools && tools.length > 0) {
206
+ const names = tools.map((t) => `\`${t.name}\``).join(', ');
207
+ parts.push('You have the following tools available: ' +
208
+ names +
209
+ '. Use them whenever the user’s request depends on information ' +
210
+ 'you can’t reliably produce from memory (current data, time, ' +
211
+ 'account-specific facts, external APIs). Do not refuse to use a ' +
212
+ 'tool because of style or persona instructions further below — ' +
213
+ 'those control your voice, not your capabilities. When a tool is ' +
214
+ 'clearly relevant, call it before composing the final answer.');
215
+ }
216
+ parts.push(agent.systemPrompt);
217
+ if (overrides?.systemPromptSuffix)
218
+ parts.push(overrides.systemPromptSuffix);
219
+ return parts.join('\n\n');
220
+ }
187
221
  /**
188
222
  * Merge `agent.tools[]` (resolved via the global registry) with any
189
223
  * per-call extras (e.g. the user's connector tools).
@@ -206,9 +240,10 @@ class AgentRunnerService {
206
240
  * for example. The shadowing only lasts for this call.
207
241
  */
208
242
  buildToolList(agent, overrides) {
209
- const fromRegistry = agent.tools?.length
210
- ? this.toolRegistry.getToolsForAgent(agent.id, agent.tools)
211
- : [];
243
+ // Always invoke the registry — even when the agent has no whitelist —
244
+ // because alwaysOn tools (e.g. `current_time`) get folded in there and
245
+ // need to reach an agent whose `tools[]` is empty.
246
+ const fromRegistry = this.toolRegistry.getToolsForAgent(agent.id, agent.tools ?? []);
212
247
  const allowed = new Set(agent.tools ?? []);
213
248
  const extras = new Map();
214
249
  const extrasSchema = [];
@@ -41,6 +41,13 @@ export interface AgentRecord {
41
41
  * caller's userId, preserving the original behavior.
42
42
  */
43
43
  connectorOwnerUserId?: string;
44
+ /**
45
+ * IANA timezone (e.g. `"America/Bogota"`, `"Europe/Madrid"`). The
46
+ * built-in `current_time` tool reads this to anchor "what time is it"
47
+ * answers to the agent owner's locale instead of the host server's UTC.
48
+ * Defaults to `"UTC"` when unset.
49
+ */
50
+ timezone?: string;
44
51
  }
45
52
  /**
46
53
  * Host-supplied resolver for per-tenant agent configurations. The SDK never
@@ -167,6 +167,7 @@ class AgentService {
167
167
  conversationId: params.conversationId,
168
168
  agentId: conv.agentId,
169
169
  messageId: 'sync',
170
+ agent: { timezone: agent.timezone },
170
171
  }, { ...(params.overrides ?? {}), extraTools });
171
172
  await this.conversations.addMessage({
172
173
  conversationId: params.conversationId,
@@ -230,6 +231,7 @@ class AgentService {
230
231
  conversationId: params.conversationId,
231
232
  agentId: conv.agentId,
232
233
  messageId: 'streaming',
234
+ agent: { timezone: agent.timezone },
233
235
  }, { ...(params.overrides ?? {}), extraTools })) {
234
236
  if (chunk.type === 'text_delta')
235
237
  fullContent += chunk.delta;
@@ -338,6 +340,7 @@ function toAgentDefinition(record) {
338
340
  mcpServers: record.mcpServers,
339
341
  metadata: record.metadata,
340
342
  connectorOwnerUserId: record.connectorOwnerUserId,
343
+ timezone: record.timezone,
341
344
  ...(record.slug !== undefined ? { slug: record.slug } : {}),
342
345
  ...(extra.appearance !== undefined ? { appearance: extra.appearance } : {}),
343
346
  };
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ToolRegistryService = void 0;
4
+ const current_time_tool_1 = require("../builtins/current-time.tool");
4
5
  const noopLogger = {
5
6
  log: () => { },
6
7
  warn: () => { },
@@ -16,6 +17,10 @@ class ToolRegistryService {
16
17
  constructor(opts = {}) {
17
18
  this.tools = new Map();
18
19
  this.logger = opts.logger ?? noopLogger;
20
+ // Built-in essentials registered before host tools so a host can still
21
+ // override by registering the same name (rare — but the explicit Map
22
+ // overwrite warning makes the swap visible).
23
+ this.register((0, current_time_tool_1.currentTimeTool)());
19
24
  if (opts.initialTools)
20
25
  this.registerMany(opts.initialTools);
21
26
  }
@@ -39,7 +44,18 @@ class ToolRegistryService {
39
44
  return existed;
40
45
  }
41
46
  getToolsForAgent(agentId, toolNames) {
42
- return toolNames
47
+ // Always-on tools (e.g. `current_time`) are offered to every agent
48
+ // regardless of the whitelist — they're runtime essentials the model
49
+ // needs to answer correctly. Per-agent allowlists still apply.
50
+ const requested = new Set(toolNames);
51
+ for (const t of this.tools.values()) {
52
+ if (!t.alwaysOn)
53
+ continue;
54
+ if (t.agents && t.agents.length > 0 && !t.agents.includes(agentId))
55
+ continue;
56
+ requested.add(t.name);
57
+ }
58
+ return Array.from(requested)
43
59
  .filter((name) => {
44
60
  const tool = this.tools.get(name);
45
61
  if (!tool) {
@@ -97,6 +97,14 @@ export interface AgentToolDefinition {
97
97
  /** MCP server this tool was discovered from. Set by McpClientService when
98
98
  * wrapping a remote tool; left undefined for built-ins and connector tools. */
99
99
  mcpServerName?: string;
100
+ /**
101
+ * When true, the tool is offered to every agent regardless of whether the
102
+ * agent's `tools[]` whitelist mentions it. Used for runtime essentials
103
+ * like `current_time` that should never silently be off — the model
104
+ * needs them to answer correctly. UIs can still surface them in the
105
+ * picker as informational rows.
106
+ */
107
+ alwaysOn?: boolean;
100
108
  /** Owning tenantId for tenant-scoped tools (MCP servers registered per
101
109
  * tenant). Used by `/tools` to filter results to the caller's tenant so
102
110
  * the catalog doesn't leak another workspace's rows. Built-ins leave
@@ -108,6 +116,15 @@ export interface ToolExecutionContext {
108
116
  conversationId: string;
109
117
  agentId: string;
110
118
  messageId: string;
119
+ /**
120
+ * Agent-level configuration the tool may consult — e.g. `current_time`
121
+ * reads `timezone` to anchor results to the agent owner's locale. Set
122
+ * by the runner when it dispatches; tools should treat any field as
123
+ * optional and fall back to sensible defaults.
124
+ */
125
+ agent?: {
126
+ timezone?: string;
127
+ };
111
128
  }
112
129
  export type AnthropicMessage = Anthropic.MessageParam;
113
130
  export type AnthropicTool = Anthropic.Tool;
@@ -103,6 +103,12 @@ export interface AgentDefinition {
103
103
  * userId, preserving the original behavior.
104
104
  */
105
105
  connectorOwnerUserId?: string;
106
+ /**
107
+ * IANA timezone string (e.g. `"America/Bogota"`). The built-in
108
+ * `current_time` tool reads this so date/time questions resolve to the
109
+ * agent owner's locale instead of UTC. Defaults to `"UTC"` when unset.
110
+ */
111
+ timezone?: string;
106
112
  }
107
113
  /**
108
114
  * Configuration for an MCP server the runtime should connect to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Framework-free AI runtime SDK. Owns: agent loop (Anthropic), conversations, tools, streaming, agent-job queue, SdkHooks. Identity, billing, infra (email/uploads/secrets) live in the host's modules — not here.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",