@agentforge-io/core 3.0.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -62,6 +62,18 @@ export interface ConnectorAuth {
62
62
  /** Soft toggle. UI revoke flips this to false and the runtime stops
63
63
  * resolving the token for tool calls. */
64
64
  isActive: boolean;
65
+ /**
66
+ * Per-installation context captured at authorize-time. Carries the
67
+ * data the tool runtime + refresh path need that is NOT in the token
68
+ * itself: today only `shop` (for Shopify, where the API base is
69
+ * `https://{shop}.myshopify.com/admin/api/...`).
70
+ *
71
+ * Free-form JSONB so future per-installation providers (Atlassian
72
+ * Cloud `cloudId`, Intercom region, etc.) can stamp their own keys
73
+ * without another schema bump. Connectors are responsible for
74
+ * documenting which keys they read.
75
+ */
76
+ metadata?: Record<string, unknown>;
65
77
  createdAt: Date;
66
78
  updatedAt: Date;
67
79
  }
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).
@@ -5,7 +5,7 @@ import type { ToolRegistryService } from './tool-registry.service';
5
5
  import type { AgentToolDefinition } from '../types';
6
6
  export declare class ConnectorError extends Error {
7
7
  status: number;
8
- code: 'unknown_connector' | 'not_connected' | 'not_configured' | 'invalid_state' | 'token_exchange_failed' | 'invalid_api_key' | 'wrong_auth_kind';
8
+ code: 'unknown_connector' | 'not_connected' | 'not_configured' | 'invalid_state' | 'token_exchange_failed' | 'invalid_api_key' | 'wrong_auth_kind' | 'missing_installation_context';
9
9
  constructor(code: ConnectorError['code'], message: string);
10
10
  }
11
11
  /**
@@ -35,6 +35,18 @@ export interface AuthorizeState {
35
35
  * persisted row and in the UI as "Connected by [Admin Name]".
36
36
  */
37
37
  tenantId?: string;
38
+ /**
39
+ * Per-installation domain captured at authorize-time and propagated
40
+ * to the token-exchange step. Only used by providers whose OAuth URLs
41
+ * are per-shop (Shopify); for the other connectors this stays
42
+ * undefined and `OAuth2Service.resolveEndpoints` falls back to the
43
+ * static URLs.
44
+ *
45
+ * Persisted on the resulting `ConnectorAuth.metadata.shop` so the
46
+ * connector's tool runtime + the refresh path can reconstruct the
47
+ * per-shop API base without round-tripping to the user.
48
+ */
49
+ shop?: string;
38
50
  }
39
51
  /**
40
52
  * Pluggable storage for the short-lived state map. Defaults to an
@@ -137,6 +149,7 @@ export declare class ConnectorRegistryService {
137
149
  get(connectorId: string): ConnectorDefinition;
138
150
  startAuthorize(connectorId: string, userId: string, redirectUri: string, opts?: {
139
151
  tenantId?: string;
152
+ shop?: string;
140
153
  }): Promise<{
141
154
  url: string;
142
155
  }>;
@@ -220,6 +233,7 @@ export declare class ConnectorRegistryService {
220
233
  userId: string;
221
234
  connectedByUserId?: string;
222
235
  accountLabel?: string;
236
+ metadata?: Record<string, unknown>;
223
237
  }): Promise<void>;
224
238
  /**
225
239
  * Encrypt tokens and compute `expiresAt`. Shared by both the
@@ -139,13 +139,23 @@ class ConnectorRegistryService {
139
139
  throw new ConnectorError('not_configured', `Connector "${connectorId}" is not configured — the operator must ` +
140
140
  `set its OAuth credentials before users can connect.`);
141
141
  }
142
- const { url, state, pkceVerifier } = this.deps.oauth.buildAuthorizeUrl(def.oauth, redirectUri);
142
+ // Per-installation providers (Shopify) require a `shop` so the
143
+ // service can pick the right per-shop authorize URL. Fail loud
144
+ // here rather than at the provider — the alternative is a generic
145
+ // "invalid_client" round-trip with no breadcrumb.
146
+ if (def.oauth.resolveEndpoints && !opts?.shop) {
147
+ throw new ConnectorError('missing_installation_context', `Connector "${connectorId}" requires a per-installation domain ` +
148
+ `(e.g. shop) in startAuthorize opts. The connector defines ` +
149
+ `resolveEndpoints but no shop was provided.`);
150
+ }
151
+ const { url, state, pkceVerifier } = this.deps.oauth.buildAuthorizeUrl(def.oauth, redirectUri, opts?.shop ? { shop: opts.shop } : undefined);
143
152
  await this.stateStore.set(state, {
144
153
  userId,
145
154
  connectorId,
146
155
  pkceVerifier,
147
156
  createdAt: Date.now(),
148
157
  tenantId: opts?.tenantId,
158
+ shop: opts?.shop,
149
159
  });
150
160
  return { url };
151
161
  }
@@ -172,7 +182,7 @@ class ConnectorRegistryService {
172
182
  }
173
183
  let tokens;
174
184
  try {
175
- tokens = await this.deps.oauth.exchangeCode(def.oauth, code, redirectUri, ctx.pkceVerifier);
185
+ tokens = await this.deps.oauth.exchangeCode(def.oauth, code, redirectUri, ctx.pkceVerifier, ctx.shop ? { shop: ctx.shop } : undefined);
176
186
  }
177
187
  catch (e) {
178
188
  throw new ConnectorError('token_exchange_failed', e.message);
@@ -183,11 +193,16 @@ class ConnectorRegistryService {
183
193
  // user-scoped grants in the personal lane (one row per
184
194
  // `(userId, connectorId)`). Both branches share the cipher + token
185
195
  // shape via `buildAuthFields`.
196
+ //
197
+ // For per-installation providers (Shopify) we also stamp
198
+ // `metadata.shop` so the tool runtime + refresh path can
199
+ // reconstruct the per-shop API base without a round-trip.
200
+ const metadata = ctx.shop ? { shop: ctx.shop } : undefined;
186
201
  if (ctx.tenantId) {
187
- await this.upsertAuthForTenant(ctx.tenantId, ctx.connectorId, tokens, { userId: ctx.userId });
202
+ await this.upsertAuthForTenant(ctx.tenantId, ctx.connectorId, tokens, { userId: ctx.userId, metadata });
188
203
  }
189
204
  else {
190
- await this.upsertAuth(ctx.userId, ctx.connectorId, tokens);
205
+ await this.upsertAuth(ctx.userId, ctx.connectorId, tokens, { metadata });
191
206
  }
192
207
  return {
193
208
  connectorId: ctx.connectorId,
@@ -332,13 +347,14 @@ class ConnectorRegistryService {
332
347
  return tools;
333
348
  }
334
349
  // ─── Internals ───────────────────────────────────────────────────────────
335
- async upsertAuth(userId, connectorId, tokens) {
350
+ async upsertAuth(userId, connectorId, tokens, opts) {
336
351
  const existing = await this.deps.authRepo.findByUserAndConnector(userId, connectorId);
337
352
  const fields = this.buildAuthFields(tokens);
338
353
  if (existing) {
339
354
  await this.deps.authRepo.update(existing.id, {
340
355
  ...fields,
341
356
  isActive: true,
357
+ ...(opts?.metadata ? { metadata: opts.metadata } : {}),
342
358
  });
343
359
  }
344
360
  else {
@@ -352,6 +368,7 @@ class ConnectorRegistryService {
352
368
  // satisfies the contract.
353
369
  accessTokenEncrypted: fields.accessTokenEncrypted,
354
370
  isActive: true,
371
+ metadata: opts?.metadata,
355
372
  });
356
373
  }
357
374
  }
@@ -378,6 +395,7 @@ class ConnectorRegistryService {
378
395
  // reflects who last attached the integration.
379
396
  connectedByUserId,
380
397
  ...(opts.accountLabel ? { accountLabel: opts.accountLabel } : {}),
398
+ ...(opts.metadata ? { metadata: opts.metadata } : {}),
381
399
  });
382
400
  }
383
401
  else {
@@ -391,6 +409,7 @@ class ConnectorRegistryService {
391
409
  accessTokenEncrypted: fields.accessTokenEncrypted,
392
410
  accountLabel: opts.accountLabel,
393
411
  isActive: true,
412
+ metadata: opts.metadata,
394
413
  });
395
414
  }
396
415
  }
@@ -54,6 +54,42 @@ export interface OAuth2ProviderConfig {
54
54
  * every existing connector relies on it.
55
55
  */
56
56
  tokenAuth?: 'body' | 'basic';
57
+ /**
58
+ * Optional URL resolver for providers whose OAuth endpoints depend on
59
+ * per-installation context. Shopify is the canonical case: the
60
+ * authorize URL is `https://{shop}.myshopify.com/admin/oauth/authorize`
61
+ * and the token URL is `https://{shop}.myshopify.com/admin/oauth/access_token`
62
+ * — neither can be a static string.
63
+ *
64
+ * When set, the service calls this hook at authorize-time AND
65
+ * token-exchange/refresh-time, using the returned URLs INSTEAD of
66
+ * `authorizeUrl` / `tokenUrl`. The static fields stay required (used
67
+ * as the fallback when no `builderCtx` is passed) so existing call
68
+ * sites don't crash.
69
+ *
70
+ * The returned shape is `{ authorizeUrl, tokenUrl }` — same two fields
71
+ * — because Shopify (and similar providers) move both endpoints to
72
+ * the same per-shop domain. Returning both keeps the resolver atomic:
73
+ * we never get into a state where the authorize URL points at shop A
74
+ * and the token URL points at shop B.
75
+ */
76
+ resolveEndpoints?: (ctx: OAuthBuilderContext) => {
77
+ authorizeUrl: string;
78
+ tokenUrl: string;
79
+ };
80
+ }
81
+ /**
82
+ * Context handed to `OAuth2ProviderConfig.resolveEndpoints` when the
83
+ * provider's URLs are per-installation. Optional fields so providers
84
+ * can subscribe to whatever they need; today only `shop` is used (by
85
+ * Shopify), but we leave room for future regional providers (Atlassian
86
+ * Cloud, Google Workspace EU, etc.) without another minor bump.
87
+ */
88
+ export interface OAuthBuilderContext {
89
+ /** Per-installation domain. For Shopify: `tienda.myshopify.com`. */
90
+ shop?: string;
91
+ /** Optional region hint for providers with regional endpoints. */
92
+ region?: string;
57
93
  }
58
94
  export interface AuthorizeUrlResult {
59
95
  url: string;
@@ -84,9 +120,19 @@ interface OAuth2ServiceDeps {
84
120
  export declare class OAuth2Service {
85
121
  private readonly fetchImpl;
86
122
  constructor(deps?: OAuth2ServiceDeps);
87
- buildAuthorizeUrl(cfg: OAuth2ProviderConfig, redirectUri: string): AuthorizeUrlResult;
88
- exchangeCode(cfg: OAuth2ProviderConfig, code: string, redirectUri: string, pkceVerifier?: string): Promise<TokenSet>;
89
- refresh(cfg: OAuth2ProviderConfig, refreshToken: string): Promise<TokenSet>;
123
+ buildAuthorizeUrl(cfg: OAuth2ProviderConfig, redirectUri: string, builderCtx?: OAuthBuilderContext): AuthorizeUrlResult;
124
+ exchangeCode(cfg: OAuth2ProviderConfig, code: string, redirectUri: string, pkceVerifier?: string, builderCtx?: OAuthBuilderContext): Promise<TokenSet>;
125
+ refresh(cfg: OAuth2ProviderConfig, refreshToken: string, builderCtx?: OAuthBuilderContext): Promise<TokenSet>;
126
+ /**
127
+ * Resolve the actual (authorizeUrl, tokenUrl) pair for a given call.
128
+ * When the provider declares `resolveEndpoints`, the resolver is
129
+ * invoked with the per-call context (e.g. `{ shop }` for Shopify);
130
+ * otherwise we fall back to the static fields. This is the single
131
+ * choke point so authorize / exchange / refresh stay in sync — there
132
+ * is no path where authorize uses the per-shop URL but refresh
133
+ * accidentally hits the static one.
134
+ */
135
+ private resolveEndpoints;
90
136
  private postToken;
91
137
  }
92
138
  export {};
@@ -12,7 +12,8 @@ class OAuth2Service {
12
12
  constructor(deps = {}) {
13
13
  this.fetchImpl = deps.fetchImpl ?? fetch;
14
14
  }
15
- buildAuthorizeUrl(cfg, redirectUri) {
15
+ buildAuthorizeUrl(cfg, redirectUri, builderCtx) {
16
+ const resolved = this.resolveEndpoints(cfg, builderCtx);
16
17
  const state = (0, crypto_1.randomBytes)(16).toString('hex');
17
18
  const usePkce = cfg.usePkce !== false;
18
19
  const params = {
@@ -30,12 +31,12 @@ class OAuth2Service {
30
31
  params.code_challenge = challenge;
31
32
  params.code_challenge_method = 'S256';
32
33
  }
33
- const url = new URL(cfg.authorizeUrl);
34
+ const url = new URL(resolved.authorizeUrl);
34
35
  for (const [k, v] of Object.entries(params))
35
36
  url.searchParams.set(k, v);
36
37
  return { url: url.toString(), state, pkceVerifier };
37
38
  }
38
- async exchangeCode(cfg, code, redirectUri, pkceVerifier) {
39
+ async exchangeCode(cfg, code, redirectUri, pkceVerifier, builderCtx) {
39
40
  const body = new URLSearchParams({
40
41
  grant_type: 'authorization_code',
41
42
  code,
@@ -51,9 +52,9 @@ class OAuth2Service {
51
52
  }
52
53
  if (pkceVerifier)
53
54
  body.set('code_verifier', pkceVerifier);
54
- return this.postToken(cfg, body);
55
+ return this.postToken(cfg, body, builderCtx);
55
56
  }
56
- async refresh(cfg, refreshToken) {
57
+ async refresh(cfg, refreshToken, builderCtx) {
57
58
  const body = new URLSearchParams({
58
59
  grant_type: 'refresh_token',
59
60
  refresh_token: refreshToken,
@@ -62,9 +63,25 @@ class OAuth2Service {
62
63
  body.set('client_id', cfg.clientId);
63
64
  body.set('client_secret', cfg.clientSecret);
64
65
  }
65
- return this.postToken(cfg, body);
66
+ return this.postToken(cfg, body, builderCtx);
66
67
  }
67
- async postToken(cfg, body) {
68
+ /**
69
+ * Resolve the actual (authorizeUrl, tokenUrl) pair for a given call.
70
+ * When the provider declares `resolveEndpoints`, the resolver is
71
+ * invoked with the per-call context (e.g. `{ shop }` for Shopify);
72
+ * otherwise we fall back to the static fields. This is the single
73
+ * choke point so authorize / exchange / refresh stay in sync — there
74
+ * is no path where authorize uses the per-shop URL but refresh
75
+ * accidentally hits the static one.
76
+ */
77
+ resolveEndpoints(cfg, builderCtx) {
78
+ if (cfg.resolveEndpoints) {
79
+ return cfg.resolveEndpoints(builderCtx ?? {});
80
+ }
81
+ return { authorizeUrl: cfg.authorizeUrl, tokenUrl: cfg.tokenUrl };
82
+ }
83
+ async postToken(cfg, body, builderCtx) {
84
+ const resolved = this.resolveEndpoints(cfg, builderCtx);
68
85
  const headers = {
69
86
  'content-type': 'application/x-www-form-urlencoded',
70
87
  accept: 'application/json',
@@ -76,14 +93,14 @@ class OAuth2Service {
76
93
  const credentials = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64');
77
94
  headers.authorization = `Basic ${credentials}`;
78
95
  }
79
- const res = await this.fetchImpl(cfg.tokenUrl, {
96
+ const res = await this.fetchImpl(resolved.tokenUrl, {
80
97
  method: 'POST',
81
98
  headers,
82
99
  body: body.toString(),
83
100
  });
84
101
  if (!res.ok) {
85
102
  const text = await res.text().catch(() => '');
86
- throw new Error(`OAuth2 token endpoint ${cfg.tokenUrl} returned ${res.status}: ${text}`);
103
+ throw new Error(`OAuth2 token endpoint ${resolved.tokenUrl} returned ${res.status}: ${text}`);
87
104
  }
88
105
  const json = (await res.json());
89
106
  const tokens = cfg.tokenExtractor
@@ -95,7 +112,7 @@ class OAuth2Service {
95
112
  // here with a descriptive error so the connector author knows their
96
113
  // extractor (or the default) missed the token.
97
114
  if (!tokens.accessToken) {
98
- throw new Error(`OAuth2 token endpoint ${cfg.tokenUrl} returned a response without an access token. ` +
115
+ throw new Error(`OAuth2 token endpoint ${resolved.tokenUrl} returned a response without an access token. ` +
99
116
  'If the provider uses a non-standard envelope, supply `tokenExtractor` on the ConnectorDefinition.');
100
117
  }
101
118
  return tokens;
@@ -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": "3.0.0",
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 \u2014 not here.",
3
+ "version": "3.3.0",
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",
@@ -33,4 +33,4 @@
33
33
  "tsx": "^4.19.0",
34
34
  "typescript": "^5.0.0"
35
35
  }
36
- }
36
+ }