@agentforge-io/core 2.3.1 → 3.0.1

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.
@@ -6,12 +6,15 @@ import type { AgentToolDefinition } from '../types/agent.types';
6
6
  * date?" / "what time is it in Bogotá?" answers drift to either the
7
7
  * training cutoff (months off) or the host server's UTC, neither of
8
8
  * which match the user's expectations. This tool anchors every time
9
- * question to the agent's configured IANA timezone.
9
+ * question to the right IANA timezone via a fallback chain:
10
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.
11
+ * 1. The explicit `timezone` argument if the model passed one.
12
+ * 2. `context.agent.timezone` set by the runner from the agent
13
+ * record (per-agent override).
14
+ * 3. `context.tenant.timezone` set by the host's TenantResolver,
15
+ * so the workspace default covers every agent that hasn't been
16
+ * individually configured.
17
+ * 4. `"UTC"` as a last-resort floor so the call always succeeds.
15
18
  *
16
19
  * Output is a compact JSON object — easier for the model to quote
17
20
  * specific pieces (just the weekday, just the ISO) than a natural-
@@ -9,12 +9,15 @@ exports.currentTimeTool = currentTimeTool;
9
9
  * date?" / "what time is it in Bogotá?" answers drift to either the
10
10
  * training cutoff (months off) or the host server's UTC, neither of
11
11
  * which match the user's expectations. This tool anchors every time
12
- * question to the agent's configured IANA timezone.
12
+ * question to the right IANA timezone via a fallback chain:
13
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.
14
+ * 1. The explicit `timezone` argument if the model passed one.
15
+ * 2. `context.agent.timezone` set by the runner from the agent
16
+ * record (per-agent override).
17
+ * 3. `context.tenant.timezone` set by the host's TenantResolver,
18
+ * so the workspace default covers every agent that hasn't been
19
+ * individually configured.
20
+ * 4. `"UTC"` as a last-resort floor so the call always succeeds.
18
21
  *
19
22
  * Output is a compact JSON object — easier for the model to quote
20
23
  * specific pieces (just the weekday, just the ISO) than a natural-
@@ -25,18 +28,27 @@ function currentTimeTool() {
25
28
  return {
26
29
  name: exports.CURRENT_TIME_TOOL_NAME,
27
30
  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.',
31
+ description: 'Look up the current wall-clock date and time silently, for use in ' +
32
+ 'your own reasoning. Call this whenever an answer depends on knowing ' +
33
+ 'the real-world moment greetings keyed to time of day, deadlines ' +
34
+ 'relative to "today", business-hours decisions, scheduling, or any ' +
35
+ 'user phrasing like "now", "today", "this week". ' +
36
+ "The result is anchored to the agent's configured timezone (or the " +
37
+ 'workspace default) unless the caller supplies an explicit ' +
38
+ '`timezone` argument. ' +
39
+ 'IMPORTANT: do NOT recite the timestamp back to the user verbatim ' +
40
+ 'unless they explicitly asked what time or date it is. Use the value ' +
41
+ 'to inform your reply (e.g. choose "Good evening" vs "Good morning", ' +
42
+ 'pick the right greeting, compute "in 3 days"); quoting the raw ' +
43
+ 'clock reading in every message reads as robotic.',
33
44
  inputSchema: {
34
45
  type: 'object',
35
46
  properties: {
36
47
  timezone: {
37
48
  type: 'string',
38
49
  description: 'Optional IANA timezone (e.g. "America/Bogota", "Europe/Madrid", ' +
39
- '"Asia/Tokyo"). Defaults to the agent\'s configured timezone.',
50
+ '"Asia/Tokyo"). Defaults to the agent\'s configured timezone, ' +
51
+ "then the tenant's default timezone, then UTC.",
40
52
  },
41
53
  },
42
54
  additionalProperties: false,
@@ -45,7 +57,7 @@ function currentTimeTool() {
45
57
  const requested = typeof input.timezone === 'string' && input.timezone.trim()
46
58
  ? input.timezone.trim()
47
59
  : undefined;
48
- const fallback = context.agent?.timezone || 'UTC';
60
+ const fallback = context.agent?.timezone || context.tenant?.timezone || 'UTC';
49
61
  const tz = requested ?? fallback;
50
62
  const now = new Date();
51
63
  let iso;
package/dist/index.d.ts CHANGED
@@ -11,6 +11,6 @@ export { JOB_QUEUE, type JobQueue, type JobStatus, type JobState, type JobContex
11
11
  export { InMemoryJobQueue, type InMemoryJobQueueOptions, } from './adapters/job-queue/in-memory';
12
12
  export * from './providers';
13
13
  export * from './services';
14
- export type { AgentResolver, AgentRecord, AgentResolveParams, } from './services/agent.service';
14
+ export type { AgentResolver, AgentRecord, AgentResolveParams, TenantResolver, TenantContext, } from './services/agent.service';
15
15
  export { toAgentDefinition } from './services/agent.service';
16
16
  export { createAgentForge, type CreateAgentForgeOptions, type AgentForgeContainer, type AgentForgeRepositories, type AgentForgeAdapters, } from './factory';
@@ -76,6 +76,31 @@ export interface AgentResolver {
76
76
  findByIdForTenant(id: string, tenantId: string): Promise<AgentRecord | null>;
77
77
  findById(id: string): Promise<AgentRecord | null>;
78
78
  }
79
+ /**
80
+ * Tenant-level context the SDK passes to tools. The host owns tenant
81
+ * records; the SDK doesn't query them directly. Implement this
82
+ * interface in the host and register it via
83
+ * `AgentService.setTenantResolver(...)` so the runner can populate
84
+ * `ToolExecutionContext.tenant` for tools like `current_time` that
85
+ * need a workspace-wide default (e.g. timezone) when the agent itself
86
+ * has none configured.
87
+ *
88
+ * Returning `null` means "no tenant context available" — tools fall
89
+ * through to their own defaults (UTC for `current_time`). Returning a
90
+ * partial object is fine; the SDK only reads the fields tools ask for.
91
+ */
92
+ export interface TenantResolver {
93
+ findById(tenantId: string): Promise<TenantContext | null>;
94
+ }
95
+ /** Minimal tenant-level context the SDK forwards to tools today. Add
96
+ * fields here as new tools start consuming tenant settings; keep them
97
+ * optional to preserve compatibility with hosts that don't populate
98
+ * every field. */
99
+ export interface TenantContext {
100
+ /** IANA timezone (e.g. `"America/Bogota"`). Used by `current_time`
101
+ * as the workspace default when the agent has none. */
102
+ timezone?: string;
103
+ }
79
104
  export interface AgentNotFoundError extends Error {
80
105
  status: 404;
81
106
  }
@@ -141,6 +166,23 @@ export declare class AgentService {
141
166
  * `delegate_to_*` synthetic tools fire. Standalone agents always
142
167
  * go straight to the runner. */
143
168
  orchestrator?: import("./orchestrator.service").OrchestratorService | undefined);
169
+ /** Optional host-supplied tenant resolver. When wired, every
170
+ * streamMessage / sendMessage attaches `tenant: { timezone }` (and
171
+ * whatever else TenantContext grows) to the ToolExecutionContext so
172
+ * tools like `current_time` can fall back to the workspace default.
173
+ * Left undefined for SDK consumers without a tenant model. */
174
+ private tenantResolver?;
175
+ /** Register the host's tenant resolver. Called once at module init
176
+ * (after construction so we don't bloat the positional constructor
177
+ * arg list for every SDK consumer). Idempotent — calling twice
178
+ * overwrites the previous resolver. */
179
+ setTenantResolver(resolver: TenantResolver): void;
180
+ /** Load the tenant context (currently just timezone) for an agent
181
+ * record. Returns `undefined` when the agent has no tenantId, no
182
+ * resolver is wired, or the lookup throws / returns null — in every
183
+ * one of those cases the tool falls back to its own defaults so a
184
+ * resolver outage never breaks the turn. */
185
+ private loadTenantContext;
144
186
  /** Public re-export of `resolveExtraTools` keyed by agentId. The
145
187
  * orchestrator uses this via `setExtraToolsResolver` to hydrate
146
188
  * sub-agents' connector tools at delegation time. We synthesize a
@@ -64,6 +64,33 @@ class AgentService {
64
64
  return this.resolveExtraToolsForAgent(agentId, userId);
65
65
  });
66
66
  }
67
+ /** Register the host's tenant resolver. Called once at module init
68
+ * (after construction so we don't bloat the positional constructor
69
+ * arg list for every SDK consumer). Idempotent — calling twice
70
+ * overwrites the previous resolver. */
71
+ setTenantResolver(resolver) {
72
+ this.tenantResolver = resolver;
73
+ }
74
+ /** Load the tenant context (currently just timezone) for an agent
75
+ * record. Returns `undefined` when the agent has no tenantId, no
76
+ * resolver is wired, or the lookup throws / returns null — in every
77
+ * one of those cases the tool falls back to its own defaults so a
78
+ * resolver outage never breaks the turn. */
79
+ async loadTenantContext(agent) {
80
+ if (!this.tenantResolver || !agent.tenantId)
81
+ return undefined;
82
+ try {
83
+ const ctx = await this.tenantResolver.findById(agent.tenantId);
84
+ if (!ctx)
85
+ return undefined;
86
+ return { timezone: ctx.timezone };
87
+ }
88
+ catch {
89
+ // Resolver outage shouldn't break the turn — the tool's own
90
+ // fallback (agent.timezone → UTC) still produces a sane answer.
91
+ return undefined;
92
+ }
93
+ }
67
94
  /** Public re-export of `resolveExtraTools` keyed by agentId. The
68
95
  * orchestrator uses this via `setExtraToolsResolver` to hydrate
69
96
  * sub-agents' connector tools at delegation time. We synthesize a
@@ -313,12 +340,14 @@ class AgentService {
313
340
  // ChatStreamController). Caller wins on name collisions so an
314
341
  // explicit override always trumps an inherited connector tool.
315
342
  const extraTools = mergeExtraTools(params.overrides?.extraTools, fromConnectors);
343
+ const tenant = await this.loadTenantContext(agent);
316
344
  const response = await this.runner.run(agent, messages, {
317
345
  userId: params.userId,
318
346
  conversationId: params.conversationId,
319
347
  agentId: conv.agentId,
320
348
  messageId: 'sync',
321
349
  agent: { timezone: agent.timezone },
350
+ ...(tenant ? { tenant } : {}),
322
351
  }, { ...(params.overrides ?? {}), extraTools });
323
352
  await this.conversations.addMessage({
324
353
  conversationId: params.conversationId,
@@ -379,6 +408,7 @@ class AgentService {
379
408
  const filter = params.overrides?.extraToolsFilter;
380
409
  const fromConnectors = filter && resolvedExtras ? filter(resolvedExtras) : resolvedExtras;
381
410
  const extraTools = mergeExtraTools(params.overrides?.extraTools, fromConnectors);
411
+ const tenant = await this.loadTenantContext(agent);
382
412
  // Hoisted accumulators so the post-loop persistence (after the
383
413
  // try) can see the final list. Defined here, populated inside
384
414
  // the for-await loop below.
@@ -400,6 +430,7 @@ class AgentService {
400
430
  agentId: conv.agentId,
401
431
  messageId: 'streaming',
402
432
  agent: { timezone: agent.timezone },
433
+ ...(tenant ? { tenant } : {}),
403
434
  })
404
435
  : this.runner.stream(agent, messages, {
405
436
  userId: params.userId,
@@ -407,6 +438,7 @@ class AgentService {
407
438
  agentId: conv.agentId,
408
439
  messageId: 'streaming',
409
440
  agent: { timezone: agent.timezone },
441
+ ...(tenant ? { tenant } : {}),
410
442
  }, { ...(params.overrides ?? {}), extraTools });
411
443
  // Accumulate tool_use / tool_result chunks during streaming so
412
444
  // we can persist them on the assistant message row (line ~700).
@@ -142,6 +142,11 @@ class OrchestratorService {
142
142
  return;
143
143
  }
144
144
  this.logger.debug(`Streaming orchestrator "${agentId}" with subagents: ${agent.subAgents.join(', ')}`);
145
+ // Pre-resolve all sub-agents so `buildDelegationTools` sees their
146
+ // name/description/slug — without this, the agentsMap only has the
147
+ // orchestrator and the tool-name builder falls back to the UUID
148
+ // form, defeating the slug→name match the model relies on.
149
+ await Promise.all(agent.subAgents.map((id) => this.resolveAgentDynamic(id)));
145
150
  const delegationTools = this.buildDelegationTools(agent);
146
151
  const model = agent.model ?? this.anthropicConfig.defaultModel ?? 'claude-opus-4-6';
147
152
  const maxTokens = agent.maxTokens ?? this.anthropicConfig.defaultMaxTokens ?? 4096;
@@ -213,8 +218,14 @@ class OrchestratorService {
213
218
  if (block.type !== 'tool_use')
214
219
  continue;
215
220
  const delegateTool = delegationTools.find((t) => t.name === block.name);
216
- if (!delegateTool)
221
+ if (!delegateTool) {
222
+ // The model called a tool name we didn't expose. Log loudly
223
+ // so we can spot drift between the prompt's slug references
224
+ // and the actual tool surface.
225
+ this.logger.warn(`Orchestrator "${agent.id}" called unknown tool "${block.name}". ` +
226
+ `Available tools: ${delegationTools.map((t) => t.name).join(', ')}`);
217
227
  continue;
228
+ }
218
229
  const { task } = block.input;
219
230
  const { subAgentId } = delegateTool;
220
231
  const subAgent = await this.resolveAgentDynamic(subAgentId);
@@ -333,6 +344,9 @@ class OrchestratorService {
333
344
  }
334
345
  async runOrchestratorLoop(orchestrator, messages, context) {
335
346
  const delegations = [];
347
+ // Pre-resolve subagents so buildDelegationTools sees their
348
+ // slug/name/description (same fix as the stream path).
349
+ await Promise.all((orchestrator.subAgents ?? []).map((id) => this.resolveAgentDynamic(id)));
336
350
  const delegationTools = this.buildDelegationTools(orchestrator);
337
351
  const model = orchestrator.model ?? this.anthropicConfig.defaultModel ?? 'claude-opus-4-6';
338
352
  const maxTokens = orchestrator.maxTokens ?? this.anthropicConfig.defaultMaxTokens ?? 4096;
@@ -439,13 +453,43 @@ class OrchestratorService {
439
453
  }
440
454
  // ─── Helpers ───────────────────────────────────────────────────────────────
441
455
  buildDelegationTools(orchestrator) {
456
+ // First pass: collect the slug/uuid-name candidates so we can
457
+ // detect collisions across the team. Anthropic requires `tool.name`
458
+ // to be unique within a single request; two subagents with the
459
+ // same slug (rare but legal at the platform layer in different
460
+ // tenant scopes) must not collapse into one tool.
461
+ const namesUsed = new Set();
442
462
  return (orchestrator.subAgents ?? []).map((subAgentId) => {
443
463
  const sub = this.agentsMap.get(subAgentId);
464
+ const subSlug = sub?.slug;
465
+ // Tool name strategy: prefer `delegate_to_<slug>` because the model
466
+ // already SEES `@<slug>` in its system prompt; matching it to the
467
+ // tool name eliminates the two-namespace gap that caused
468
+ // wrong-tool calls. Fall back to a sanitized UUID-derived name
469
+ // when no slug is available or when the slug name is already
470
+ // taken by an earlier subagent in this build pass.
471
+ const slugName = subSlug
472
+ ? `delegate_to_${subSlug.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50)}`
473
+ : null;
474
+ const uuidName = `delegate_to_${subAgentId.replace(/[^a-zA-Z0-9]/g, '_')}`;
475
+ let name;
476
+ if (slugName && !namesUsed.has(slugName)) {
477
+ name = slugName;
478
+ }
479
+ else {
480
+ name = uuidName;
481
+ }
482
+ namesUsed.add(name);
483
+ // Description still calls out the slug explicitly — belt and
484
+ // suspenders against models that pattern-match on text more
485
+ // than on tool names.
444
486
  const description = sub
445
- ? `Delegate a task to the "${sub.name}" specialist agent. ${sub.description ?? ''}`
487
+ ? `Delegate a task to the "${sub.name}" specialist agent` +
488
+ (subSlug ? ` (mention slug: @${subSlug})` : '') +
489
+ `. ${sub.description ?? ''}`
446
490
  : `Delegate a task to subagent "${subAgentId}"`;
447
491
  return {
448
- name: `delegate_to_${subAgentId.replace(/[^a-zA-Z0-9]/g, '_')}`,
492
+ name,
449
493
  description,
450
494
  subAgentId,
451
495
  inputSchema: {
@@ -147,6 +147,17 @@ export interface ToolExecutionContext {
147
147
  agent?: {
148
148
  timezone?: string;
149
149
  };
150
+ /**
151
+ * Tenant-level configuration the tool may consult as a fallback when
152
+ * the agent itself does not specify a value. `current_time` walks the
153
+ * chain `agent.timezone → tenant.timezone → 'UTC'` so a workspace
154
+ * default covers every agent that hasn't been individually configured.
155
+ * Populated by the host's `TenantResolver` hook on AgentService;
156
+ * absent when no resolver is wired (legacy SDK consumers).
157
+ */
158
+ tenant?: {
159
+ timezone?: string;
160
+ };
150
161
  }
151
162
  export type AnthropicMessage = Anthropic.MessageParam;
152
163
  export type AnthropicTool = Anthropic.Tool;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.3.1",
4
- "description": "Framework-free AI runtime SDK. Owns: agent loop (pluggable LLM provider — Anthropic by default, LangChain-backed providers as drop-ins), conversations, tools, streaming, agent-job queue, SdkHooks. Identity, billing, infra (email/uploads/secrets) live in the host's modules — not here.",
3
+ "version": "3.0.1",
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",
7
7
  "types": "dist/index.d.ts",