@agentforge-io/core 2.1.0 → 2.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.
@@ -1,19 +1,46 @@
1
1
  /**
2
- * Per-user OAuth credentials for a connector (Google, Notion, Slack, etc.).
2
+ * OAuth credentials for a connector (Google, Notion, Slack, etc.).
3
+ *
4
+ * Two scopes coexist on the same row family, distinguished by whether
5
+ * `tenantId` is set:
6
+ *
7
+ * • **User-scoped** (`tenantId` undefined): legacy / personal. One
8
+ * row per (userId, connectorId). Used by assist agents bound to
9
+ * the platform admin's identity (Prompt Assist, Onboarding
10
+ * Assist, etc.) and by personal-agent setups.
11
+ * • **Tenant-scoped** (`tenantId` set): workspace-owned grant. One
12
+ * row per (tenantId, connectorId). Visible agents in a workspace
13
+ * use this scope; any workspace admin/manager — or a platform
14
+ * admin — can connect/disconnect on behalf of the workspace.
15
+ * Survives admin handoffs and personnel changes.
16
+ *
17
+ * The runtime resolves which scope to read inside
18
+ * `ConnectorRegistry.toolsForTenant` (preferred for non-assist agents)
19
+ * and `toolsForUser` (fallback for assist + legacy paths). See
20
+ * `CONNECTOR_TENANT_AUTHS_SDD.md` in the platform repo for the full
21
+ * precedence story.
3
22
  *
4
23
  * `accessTokenEncrypted` and `refreshTokenEncrypted` are AES-GCM blobs —
5
24
  * exactly the same format as `af_platform_secrets`. The service layer
6
25
  * encrypts on write and decrypts on read using MASTER_KEY; the repo never
7
26
  * sees plaintext. That keeps "leak the DB row" from leaking the token.
8
- *
9
- * Scope of identity: the row is unique per (userId, connectorId). Today
10
- * that's the dashboard user (JWT subject). When we wire chat-session-scoped
11
- * connectors later, we add a separate row family with a different identity.
12
27
  */
13
28
  export interface ConnectorAuth {
14
29
  id: string;
15
- /** Dashboard user (JWT sub). One row per (userId, connectorId). */
30
+ /** Dashboard user (JWT sub). For tenant-scoped rows this still records
31
+ * who completed the OAuth dance (same value as `connectedByUserId`
32
+ * most of the time), so we never lose human attribution. */
16
33
  userId: string;
34
+ /** Workspace this grant belongs to. `undefined` for legacy user-scoped
35
+ * rows (assist agents, personal-agent path). Set for every grant
36
+ * created through the tenant-aware OAuth flow. */
37
+ tenantId?: string;
38
+ /** Audit field — the user who clicked Connect for a tenant-scoped
39
+ * grant. Distinct from `userId` because future migrations may null
40
+ * out `userId` on tenant rows once the legacy path is fully retired;
41
+ * we keep this column dedicated to the human-attribution use case
42
+ * ("Connected by [Admin Name]" in the UI). */
43
+ connectedByUserId?: string;
17
44
  /** Stable connector id from the registry (e.g. "google", "notion"). */
18
45
  connectorId: string;
19
46
  /** Account label shown in the UI — e.g. "alice@acme.com". Pulled from
@@ -38,5 +65,17 @@ export interface ConnectorAuth {
38
65
  createdAt: Date;
39
66
  updatedAt: Date;
40
67
  }
41
- export type NewConnectorAuth = Pick<ConnectorAuth, 'id' | 'userId' | 'connectorId' | 'accessTokenEncrypted'> & Partial<Omit<ConnectorAuth, 'id' | 'userId' | 'connectorId' | 'accessTokenEncrypted' | 'createdAt' | 'updatedAt'>>;
42
- export type ConnectorAuthPatch = Partial<Omit<ConnectorAuth, 'id' | 'userId' | 'connectorId' | 'createdAt' | 'updatedAt'>>;
68
+ export type NewConnectorAuth = Pick<ConnectorAuth, 'id' | 'userId' | 'connectorId' | 'accessTokenEncrypted'> & Partial<Omit<ConnectorAuth, 'id' | 'userId' | 'connectorId' | 'accessTokenEncrypted' | 'createdAt' | 'updatedAt'>> & {
69
+ /** Optional at create time set for tenant-scoped grants. */
70
+ tenantId?: string;
71
+ /** Optional audit value — typically equal to `userId` for newly
72
+ * created tenant-scoped rows. */
73
+ connectedByUserId?: string;
74
+ };
75
+ export type ConnectorAuthPatch = Partial<Omit<ConnectorAuth, 'id' | 'userId' | 'connectorId' | 'createdAt' | 'updatedAt'>> & {
76
+ /** Allowed in a patch so an operator UI can move a legacy
77
+ * user-scoped grant into a workspace ("share with this workspace")
78
+ * without re-doing the OAuth dance. */
79
+ tenantId?: string | null;
80
+ connectedByUserId?: string | null;
81
+ };
@@ -77,11 +77,22 @@ export interface McpServerRepository {
77
77
  }
78
78
  export interface ConnectorAuthRepository {
79
79
  create(record: NewConnectorAuth): Promise<ConnectorAuth>;
80
- /** Hot path every tool call resolves the user's token here. */
80
+ /** Hot path for legacy user-scoped grants assist agents resolve
81
+ * their tokens here. Returns only rows where `tenantId IS NULL`. */
81
82
  findByUserAndConnector(userId: string, connectorId: string): Promise<ConnectorAuth | null>;
82
- /** All connectors a user has authorized for the Directory UI. */
83
+ /** All connectors a user has authorized for personal use. Returns
84
+ * only rows where `tenantId IS NULL`. */
83
85
  listForUser(userId: string): Promise<ConnectorAuth[]>;
86
+ /** Hot path for workspace-scoped grants — the public chat / agent
87
+ * playground resolves the workspace's tokens here. Returns only
88
+ * rows where `tenantId = <tenantId>`. */
89
+ findByTenantAndConnector(tenantId: string, connectorId: string): Promise<ConnectorAuth | null>;
90
+ /** All connectors a workspace has authorized — for the
91
+ * workspace-scoped Connectors page. Returns only rows where
92
+ * `tenantId = <tenantId>`. */
93
+ listForTenant(tenantId: string): Promise<ConnectorAuth[]>;
84
94
  update(id: string, patch: ConnectorAuthPatch): Promise<void>;
85
- /** Hard delete on revoke. The user can re-auth fresh if they want back in. */
95
+ /** Hard delete on revoke. The user / workspace can re-auth fresh
96
+ * if they want back in. */
86
97
  delete(id: string): Promise<void>;
87
98
  }
@@ -11,6 +11,38 @@ const model_strategy_1 = require("../types/model-strategy");
11
11
  const noopLogger = {
12
12
  log: () => { }, warn: () => { }, debug: () => { }, error: () => { },
13
13
  };
14
+ /**
15
+ * Anthropic's newer model families deprecated the `temperature` parameter
16
+ * entirely — they auto-tune sampling internally and return 400
17
+ * `invalid_request_error: \`temperature\` is deprecated for this model` if
18
+ * the caller still sends one. Older families (3.x, the original 4.0
19
+ * releases) accept it fine.
20
+ *
21
+ * Detection by string match on the model id rather than a hard-coded
22
+ * allowlist: new model ids land between SDK releases, and we don't want
23
+ * to break temperature on legacy agents the day a new family ships.
24
+ * Pattern: anything that contains `-4-5`, `-4-6`, `-4-7`, …, `-5-*`,
25
+ * `-6-*`, etc. counts as "newer." Old 4-0 / 4-1 / 3-x ids are unaffected.
26
+ *
27
+ * Heuristic, not exhaustive — if a future family lands with a different
28
+ * naming convention we'll have to extend this. The cost of being wrong
29
+ * is a single 400 the operator can fix by clearing the temperature in
30
+ * the editor; the cost of NOT filtering is the same 400 today.
31
+ */
32
+ function modelRejectsTemperature(model) {
33
+ if (!model)
34
+ return false;
35
+ // Normalize: ignore vendor prefixes like "anthropic/claude-..." and
36
+ // bracket suffixes like "claude-opus-4-7[1m]" (long-context variant).
37
+ const m = model.toLowerCase().replace(/\[[^\]]*\]/g, '');
38
+ // claude-*-4-5, 4-6, 4-7, 4-8 …
39
+ if (/claude-[a-z]+-4-([5-9])\b/.test(m))
40
+ return true;
41
+ // claude-*-5-x, claude-*-6-x, … (future major bumps)
42
+ if (/claude-[a-z]+-([5-9])-/.test(m))
43
+ return true;
44
+ return false;
45
+ }
14
46
  /**
15
47
  * Framework-free runner for Claude. Handles the agentic loop (tool calls) for
16
48
  * sync runs and exposes streaming as an `AsyncGenerator<StreamChunk>` so any
@@ -74,12 +106,17 @@ class AgentRunnerService {
74
106
  if (this.logger && selection.reason !== 'default' && selection.reason !== 'forced') {
75
107
  this.logger.debug(`[modelRouter] agent=${agent.id} ${selection.reason}=${selection.trigger} → ${model}`);
76
108
  }
109
+ // Per-turn temperature gating. The PER-MODEL filter runs INSIDE
110
+ // the loop because `model` can change between turns (model
111
+ // strategy can route a long-context turn to a different family
112
+ // than the short turns above it). Computing once outside would
113
+ // either over-strip (drop temperature for a legacy follow-up
114
+ // model) or under-strip (forward it to a new-family upgrade).
115
+ const includeTemperature = typeof temperature === 'number' && !modelRejectsTemperature(model);
77
116
  const response = await this.client.messages.create({
78
117
  model,
79
118
  max_tokens: maxTokens,
80
- // Only include temperature when explicitly declared — newer
81
- // models 400 on `temperature` when tools are present.
82
- ...(typeof temperature === 'number' ? { temperature } : {}),
119
+ ...(includeTemperature ? { temperature } : {}),
83
120
  system: systemPrompt,
84
121
  messages: currentMessages,
85
122
  tools: tools,
@@ -193,10 +230,12 @@ class AgentRunnerService {
193
230
  if (this.logger && selection.reason !== 'default' && selection.reason !== 'forced') {
194
231
  this.logger.debug(`[modelRouter] agent=${agent.id} ${selection.reason}=${selection.trigger} → ${model}`);
195
232
  }
233
+ // Per-turn temperature gating — see `run()` above for rationale.
234
+ const includeTemperature = typeof temperature === 'number' && !modelRejectsTemperature(model);
196
235
  const stream = this.client.messages.stream({
197
236
  model,
198
237
  max_tokens: maxTokens,
199
- ...(typeof temperature === 'number' ? { temperature } : {}),
238
+ ...(includeTemperature ? { temperature } : {}),
200
239
  system: systemPrompt,
201
240
  messages: currentMessages,
202
241
  tools: tools,
@@ -1,5 +1,5 @@
1
1
  import type { AgentDefinition, McpServerConfig } from '../types/config.types';
2
- import type { AgentResponse, AgentOverrides, StreamChunk } from '../types/agent.types';
2
+ import type { AgentResponse, AgentOverrides, AgentToolDefinition, StreamChunk } from '../types/agent.types';
3
3
  import type { SdkHooks } from '../types/hooks';
4
4
  import type { AgentRunnerService } from './agent-runner.service';
5
5
  import type { ConversationService } from './conversation.service';
@@ -141,6 +141,15 @@ export declare class AgentService {
141
141
  * `delegate_to_*` synthetic tools fire. Standalone agents always
142
142
  * go straight to the runner. */
143
143
  orchestrator?: import("./orchestrator.service").OrchestratorService | undefined);
144
+ /** Public re-export of `resolveExtraTools` keyed by agentId. The
145
+ * orchestrator uses this via `setExtraToolsResolver` to hydrate
146
+ * sub-agents' connector tools at delegation time. We synthesize a
147
+ * minimal `AgentRecord` from the dynamic resolver — the
148
+ * `resolveExtraTools` branches need `tenantId`, `slug`, and the
149
+ * legacy `connectorOwnerUserId` shim, all of which the resolver
150
+ * carries on the record. Standalone (non-resolver) deployments
151
+ * fall through to undefined. */
152
+ resolveExtraToolsForAgent(agentId: string, callerUserId: string): Promise<AgentToolDefinition[] | undefined>;
144
153
  /**
145
154
  * Look up the human-friendly connector name + tool description for a
146
155
  * given tool slug. Powers the friendly copy in `awaiting_approval` /
@@ -155,10 +164,31 @@ export declare class AgentService {
155
164
  */
156
165
  private describeTool;
157
166
  /**
158
- * Fetch the connector tools the user has authorized, swallowing failures.
159
- * The agent loop must keep working even if a connector's refresh token is
160
- * dead — the toolbelt just shrinks. Specific tools may still surface their
167
+ * Fetch the connector toolbelt for an agent + caller pair. The agent
168
+ * loop must keep working even if a connector's refresh token is dead
169
+ * — the toolbelt just shrinks. Specific tools may still surface their
161
170
  * own auth errors when actually invoked.
171
+ *
172
+ * Precedence (highest priority first):
173
+ *
174
+ * 1. **Tenant-scoped grants** — when the agent has a `tenantId`
175
+ * AND isn't an assist agent. This is the canonical path for
176
+ * visible workspace agents post-refactor; the workspace owns
177
+ * the integrations and any admin can manage them.
178
+ * 2. **`agent.connectorOwnerUserId` shim** — when the host's
179
+ * identity adapter populated it (today: `tenant.ownerUserId`).
180
+ * Kept so existing prod agents keep working between deploy
181
+ * and full backfill / UI cutover. Will be removed after 1-2
182
+ * stable releases.
183
+ * 3. **Caller `userId`** — the assist-agent path. Prompt Assist,
184
+ * Onboarding Assist, etc. run as the platform admin; their
185
+ * tools come from the admin's personal authorizations.
186
+ *
187
+ * Assist agents are detected by slug prefix `system-`. They live
188
+ * inside the platform admin's tenant and would otherwise match
189
+ * branch #1, which would silently strip their toolbelt (the platform
190
+ * admin's tenant has no tenant-scoped connector grants — those live
191
+ * in actual customer tenants).
162
192
  */
163
193
  private resolveExtraTools;
164
194
  private dispatchUsage;
@@ -3,6 +3,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AgentService = exports.AgentForbiddenError = void 0;
4
4
  exports.toAgentDefinition = toAgentDefinition;
5
5
  const tool_approval_gate_1 = require("./tool-approval-gate");
6
+ /**
7
+ * Slug prefix that marks an agent as a platform-internal assist agent
8
+ * (Prompt Assist, Agent Assist, Skill Assist, Onboarding Assist, etc).
9
+ * These agents live inside the platform admin's tenant and resolve
10
+ * their connector toolbelt against the admin's PERSONAL authorizations,
11
+ * not the tenant's workspace grants — see the precedence in
12
+ * `resolveExtraTools` for why this matters.
13
+ */
14
+ const ASSIST_AGENT_SLUG_PREFIX = 'system-';
15
+ function isAssistAgent(agent) {
16
+ return !!agent.slug && agent.slug.startsWith(ASSIST_AGENT_SLUG_PREFIX);
17
+ }
6
18
  class AgentForbiddenError extends Error {
7
19
  constructor(reason) {
8
20
  super(`Usage limit exceeded: ${reason}`);
@@ -41,6 +53,37 @@ class AgentService {
41
53
  this.connectorRegistry = connectorRegistry;
42
54
  this.copywriter = copywriter;
43
55
  this.orchestrator = orchestrator;
56
+ // Wire the orchestrator's extraTools hook so when an agent reached
57
+ // via a delegate_to_* call falls through to the runner, it gets
58
+ // the same connector toolset it would have on a direct call.
59
+ // Without this, sub-agents lose their Gmail/ClickUp/MCP tools the
60
+ // moment they're invoked through an orchestrator — the symptom is
61
+ // "el agente dice que no tiene la herramienta de ClickUp" while
62
+ // calling that same agent directly works fine.
63
+ this.orchestrator?.setExtraToolsResolver(async (agentId, userId) => {
64
+ return this.resolveExtraToolsForAgent(agentId, userId);
65
+ });
66
+ }
67
+ /** Public re-export of `resolveExtraTools` keyed by agentId. The
68
+ * orchestrator uses this via `setExtraToolsResolver` to hydrate
69
+ * sub-agents' connector tools at delegation time. We synthesize a
70
+ * minimal `AgentRecord` from the dynamic resolver — the
71
+ * `resolveExtraTools` branches need `tenantId`, `slug`, and the
72
+ * legacy `connectorOwnerUserId` shim, all of which the resolver
73
+ * carries on the record. Standalone (non-resolver) deployments
74
+ * fall through to undefined. */
75
+ async resolveExtraToolsForAgent(agentId, callerUserId) {
76
+ if (!this.resolver)
77
+ return undefined;
78
+ try {
79
+ const rec = await this.resolver.findById(agentId);
80
+ if (!rec)
81
+ return undefined;
82
+ return await this.resolveExtraTools(rec, callerUserId);
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
44
87
  }
45
88
  /**
46
89
  * Look up the human-friendly connector name + tool description for a
@@ -75,16 +118,63 @@ class AgentService {
75
118
  return undefined;
76
119
  }
77
120
  /**
78
- * Fetch the connector tools the user has authorized, swallowing failures.
79
- * The agent loop must keep working even if a connector's refresh token is
80
- * dead — the toolbelt just shrinks. Specific tools may still surface their
121
+ * Fetch the connector toolbelt for an agent + caller pair. The agent
122
+ * loop must keep working even if a connector's refresh token is dead
123
+ * — the toolbelt just shrinks. Specific tools may still surface their
81
124
  * own auth errors when actually invoked.
125
+ *
126
+ * Precedence (highest priority first):
127
+ *
128
+ * 1. **Tenant-scoped grants** — when the agent has a `tenantId`
129
+ * AND isn't an assist agent. This is the canonical path for
130
+ * visible workspace agents post-refactor; the workspace owns
131
+ * the integrations and any admin can manage them.
132
+ * 2. **`agent.connectorOwnerUserId` shim** — when the host's
133
+ * identity adapter populated it (today: `tenant.ownerUserId`).
134
+ * Kept so existing prod agents keep working between deploy
135
+ * and full backfill / UI cutover. Will be removed after 1-2
136
+ * stable releases.
137
+ * 3. **Caller `userId`** — the assist-agent path. Prompt Assist,
138
+ * Onboarding Assist, etc. run as the platform admin; their
139
+ * tools come from the admin's personal authorizations.
140
+ *
141
+ * Assist agents are detected by slug prefix `system-`. They live
142
+ * inside the platform admin's tenant and would otherwise match
143
+ * branch #1, which would silently strip their toolbelt (the platform
144
+ * admin's tenant has no tenant-scoped connector grants — those live
145
+ * in actual customer tenants).
82
146
  */
83
- async resolveExtraTools(userId) {
147
+ async resolveExtraTools(agent, callerUserId) {
84
148
  if (!this.connectorRegistry)
85
149
  return undefined;
150
+ // Branch 1: tenant-scoped grants for non-assist agents.
151
+ if (agent.tenantId && !isAssistAgent(agent)) {
152
+ try {
153
+ const tools = await this.connectorRegistry.toolsForTenant(agent.tenantId);
154
+ if (tools.length)
155
+ return tools;
156
+ }
157
+ catch {
158
+ // Fall through to the shim — a stale connector grant
159
+ // shouldn't strand the agent if the legacy path still
160
+ // resolves something useful.
161
+ }
162
+ }
163
+ // Branch 2: legacy connectorOwnerUserId shim. Kept for back-compat
164
+ // with hosts whose identity adapter still populates it.
165
+ if (agent.connectorOwnerUserId) {
166
+ try {
167
+ const tools = await this.connectorRegistry.toolsForUser(agent.connectorOwnerUserId);
168
+ if (tools.length)
169
+ return tools;
170
+ }
171
+ catch {
172
+ // Try the caller next.
173
+ }
174
+ }
175
+ // Branch 3: caller userId (assist agents + personal flows).
86
176
  try {
87
- const tools = await this.connectorRegistry.toolsForUser(userId);
177
+ const tools = await this.connectorRegistry.toolsForUser(callerUserId);
88
178
  return tools.length ? tools : undefined;
89
179
  }
90
180
  catch {
@@ -213,11 +303,9 @@ class AgentService {
213
303
  role: 'user',
214
304
  content: params.content,
215
305
  });
216
- // Connector tools follow `agent.connectorOwnerUserId` when the
217
- // resolved agent declares one (e.g. a public-chat agent reusing the
218
- // owner's Gmail authorization); otherwise they fall back to the
219
- // caller's userId, which is the historical personal-agent path.
220
- const resolvedExtras = await this.resolveExtraTools(agent.connectorOwnerUserId ?? params.userId);
306
+ // Connector tools follow the agent tenant → owner → caller
307
+ // precedence (see resolveExtraTools).
308
+ const resolvedExtras = await this.resolveExtraTools(agent, params.userId);
221
309
  const filter = params.overrides?.extraToolsFilter;
222
310
  const fromConnectors = filter && resolvedExtras ? filter(resolvedExtras) : resolvedExtras;
223
311
  // Merge connector tools with whatever the caller passed in
@@ -285,10 +373,9 @@ class AgentService {
285
373
  });
286
374
  let fullContent = '';
287
375
  let finalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
288
- // Same precedence as sendMessage — agent.connectorOwnerUserId takes
289
- // priority so a public-chat agent always uses the owner's connector
290
- // toolbelt regardless of which visitor session is streaming.
291
- const resolvedExtras = await this.resolveExtraTools(agent.connectorOwnerUserId ?? params.userId);
376
+ // Same precedence as sendMessage — see resolveExtraTools for the
377
+ // tenant owner caller order.
378
+ const resolvedExtras = await this.resolveExtraTools(agent, params.userId);
292
379
  const filter = params.overrides?.extraToolsFilter;
293
380
  const fromConnectors = filter && resolvedExtras ? filter(resolvedExtras) : resolvedExtras;
294
381
  const extraTools = mergeExtraTools(params.overrides?.extraTools, fromConnectors);
@@ -1,6 +1,6 @@
1
1
  import type { ConnectorDefinition } from '../domain';
2
2
  import type { ConnectorAuthRepository } from '../repositories';
3
- import type { OAuth2Service } from './oauth2.service';
3
+ import type { OAuth2Service, TokenSet } from './oauth2.service';
4
4
  import type { ToolRegistryService } from './tool-registry.service';
5
5
  import type { AgentToolDefinition } from '../types';
6
6
  export declare class ConnectorError extends Error {
@@ -27,6 +27,14 @@ export interface AuthorizeState {
27
27
  connectorId: string;
28
28
  pkceVerifier?: string;
29
29
  createdAt: number;
30
+ /**
31
+ * When set, the OAuth callback writes a tenant-scoped grant
32
+ * (`af_connector_auths.tenant_id = <tenantId>`) instead of the
33
+ * legacy user-scoped row. The `userId` field still records WHO
34
+ * clicked Connect — surfaced as `connected_by_user_id` in the
35
+ * persisted row and in the UI as "Connected by [Admin Name]".
36
+ */
37
+ tenantId?: string;
30
38
  }
31
39
  /**
32
40
  * Pluggable storage for the short-lived state map. Defaults to an
@@ -127,7 +135,9 @@ export declare class ConnectorRegistryService {
127
135
  isConfigured(connectorId: string): boolean;
128
136
  list(): ConnectorDefinition[];
129
137
  get(connectorId: string): ConnectorDefinition;
130
- startAuthorize(connectorId: string, userId: string, redirectUri: string): Promise<{
138
+ startAuthorize(connectorId: string, userId: string, redirectUri: string, opts?: {
139
+ tenantId?: string;
140
+ }): Promise<{
131
141
  url: string;
132
142
  }>;
133
143
  /**
@@ -140,6 +150,7 @@ export declare class ConnectorRegistryService {
140
150
  completeAuthorize(state: string, code: string, redirectUri: string): Promise<{
141
151
  connectorId: string;
142
152
  userId: string;
153
+ tenantId?: string;
143
154
  }>;
144
155
  /**
145
156
  * Persist a user-pasted API key as the connector credential for `userId`.
@@ -180,7 +191,44 @@ export declare class ConnectorRegistryService {
180
191
  * cached token is close to expiry.
181
192
  */
182
193
  toolsForUser(userId: string): Promise<AgentToolDefinition[]>;
194
+ /**
195
+ * Workspace-scoped equivalent of `toolsForUser`. Returns the toolbelt
196
+ * for every connector the WORKSPACE has authorized, regardless of
197
+ * which admin clicked Connect. This is the path the public chat
198
+ * widget and the agent playground take — they don't know which
199
+ * human is on the other end, but they DO know the agent's tenant.
200
+ *
201
+ * Tokens still refresh lazily via `getAccessToken`; the only thing
202
+ * that changes vs. `toolsForUser` is the row source (tenant-scoped
203
+ * grants instead of user-scoped).
204
+ */
205
+ toolsForTenant(tenantId: string): Promise<AgentToolDefinition[]>;
206
+ private synthesizeTools;
183
207
  private upsertAuth;
208
+ /**
209
+ * Tenant-scoped upsert. Same shape as `upsertAuth` but writes a
210
+ * row tagged with `tenantId` + `connectedByUserId`, hitting the
211
+ * tenant uniqueness lane on `(tenantId, connectorId)`.
212
+ *
213
+ * `connectedByUserId` is the audit-grade record of which human
214
+ * clicked Connect for the workspace; the UI surfaces it as
215
+ * "Connected by [Admin Name]" so other admins know who attached
216
+ * each integration. We default it to `userId` if the caller didn't
217
+ * pass one — they're the same thing in almost every case.
218
+ */
219
+ upsertAuthForTenant(tenantId: string, connectorId: string, tokens: TokenSet, opts: {
220
+ userId: string;
221
+ connectedByUserId?: string;
222
+ accountLabel?: string;
223
+ }): Promise<void>;
224
+ /**
225
+ * Encrypt tokens and compute `expiresAt`. Shared by both the
226
+ * user-scoped and tenant-scoped upsert paths. Pulled into a helper
227
+ * so a Google quirk ("doesn't return refresh_token on re-consent")
228
+ * has a single owner — both paths handle the missing-refresh-token
229
+ * case the same way.
230
+ */
231
+ private buildAuthFields;
184
232
  private getAccessToken;
185
233
  }
186
234
  export {};
@@ -125,7 +125,7 @@ class ConnectorRegistryService {
125
125
  return def;
126
126
  }
127
127
  // ─── OAuth flow ──────────────────────────────────────────────────────────
128
- async startAuthorize(connectorId, userId, redirectUri) {
128
+ async startAuthorize(connectorId, userId, redirectUri, opts) {
129
129
  const def = this.get(connectorId);
130
130
  if (!def.oauth) {
131
131
  // API-key connectors don't run an OAuth dance. The controller is
@@ -145,6 +145,7 @@ class ConnectorRegistryService {
145
145
  connectorId,
146
146
  pkceVerifier,
147
147
  createdAt: Date.now(),
148
+ tenantId: opts?.tenantId,
148
149
  });
149
150
  return { url };
150
151
  }
@@ -176,8 +177,23 @@ class ConnectorRegistryService {
176
177
  catch (e) {
177
178
  throw new ConnectorError('token_exchange_failed', e.message);
178
179
  }
179
- await this.upsertAuth(ctx.userId, ctx.connectorId, tokens);
180
- return { connectorId: ctx.connectorId, userId: ctx.userId };
180
+ // Choose the persistence scope based on what the authorize step
181
+ // stashed in the state cookie. Tenant-scoped grants land in the
182
+ // workspace lane (one row per `(tenantId, connectorId)`), legacy
183
+ // user-scoped grants in the personal lane (one row per
184
+ // `(userId, connectorId)`). Both branches share the cipher + token
185
+ // shape via `buildAuthFields`.
186
+ if (ctx.tenantId) {
187
+ await this.upsertAuthForTenant(ctx.tenantId, ctx.connectorId, tokens, { userId: ctx.userId });
188
+ }
189
+ else {
190
+ await this.upsertAuth(ctx.userId, ctx.connectorId, tokens);
191
+ }
192
+ return {
193
+ connectorId: ctx.connectorId,
194
+ userId: ctx.userId,
195
+ tenantId: ctx.tenantId,
196
+ };
181
197
  }
182
198
  // ─── API-key flow ────────────────────────────────────────────────────────
183
199
  /**
@@ -269,21 +285,43 @@ class ConnectorRegistryService {
269
285
  */
270
286
  async toolsForUser(userId) {
271
287
  const authed = await this.deps.authRepo.listForUser(userId);
288
+ return this.synthesizeTools(authed, userId);
289
+ }
290
+ /**
291
+ * Workspace-scoped equivalent of `toolsForUser`. Returns the toolbelt
292
+ * for every connector the WORKSPACE has authorized, regardless of
293
+ * which admin clicked Connect. This is the path the public chat
294
+ * widget and the agent playground take — they don't know which
295
+ * human is on the other end, but they DO know the agent's tenant.
296
+ *
297
+ * Tokens still refresh lazily via `getAccessToken`; the only thing
298
+ * that changes vs. `toolsForUser` is the row source (tenant-scoped
299
+ * grants instead of user-scoped).
300
+ */
301
+ async toolsForTenant(tenantId) {
302
+ const authed = await this.deps.authRepo.listForTenant(tenantId);
303
+ // Use the tenantId in the ConnectorToolContext slot reserved for
304
+ // "the human" — keeps the existing context shape but lets
305
+ // downstream tools see the workspace identity. Tools that don't
306
+ // care (most) just ignore it.
307
+ return this.synthesizeTools(authed, `tenant:${tenantId}`);
308
+ }
309
+ synthesizeTools(authed, contextUserId) {
272
310
  const tools = [];
273
311
  for (const a of authed) {
274
312
  if (!a.isActive)
275
313
  continue;
276
314
  // Skip connectors whose creds were yanked from the secrets vault —
277
315
  // we'd 401 inside the refresh step anyway, and the agent gets a
278
- // useless tool. The auth row is left intact so the user gets their
279
- // toolbelt back the moment the operator re-supplies creds.
316
+ // useless tool. The auth row is left intact so the user gets
317
+ // their toolbelt back the moment the operator re-supplies creds.
280
318
  if (!this.configured.has(a.connectorId))
281
319
  continue;
282
320
  const def = this.defs.get(a.connectorId);
283
321
  if (!def)
284
322
  continue;
285
323
  const ctx = {
286
- userId,
324
+ userId: contextUserId,
287
325
  connectorId: a.connectorId,
288
326
  getAccessToken: () => this.getAccessToken(a),
289
327
  };
@@ -296,21 +334,10 @@ class ConnectorRegistryService {
296
334
  // ─── Internals ───────────────────────────────────────────────────────────
297
335
  async upsertAuth(userId, connectorId, tokens) {
298
336
  const existing = await this.deps.authRepo.findByUserAndConnector(userId, connectorId);
299
- const expiresAt = tokens.expiresIn
300
- ? new Date(Date.now() + tokens.expiresIn * 1000)
301
- : undefined;
302
- const accessTokenEncrypted = this.deps.cipher.encrypt(tokens.accessToken);
303
- const refreshTokenEncrypted = tokens.refreshToken
304
- ? this.deps.cipher.encrypt(tokens.refreshToken)
305
- : undefined;
337
+ const fields = this.buildAuthFields(tokens);
306
338
  if (existing) {
307
339
  await this.deps.authRepo.update(existing.id, {
308
- accessTokenEncrypted,
309
- // Some providers (Google) don't return a refresh_token on
310
- // re-consent — keep the old one in that case.
311
- ...(refreshTokenEncrypted ? { refreshTokenEncrypted } : {}),
312
- expiresAt,
313
- scope: tokens.scope,
340
+ ...fields,
314
341
  isActive: true,
315
342
  });
316
343
  }
@@ -319,14 +346,79 @@ class ConnectorRegistryService {
319
346
  id: (0, crypto_1.randomUUID)(),
320
347
  userId,
321
348
  connectorId,
322
- accessTokenEncrypted,
323
- refreshTokenEncrypted,
324
- expiresAt,
325
- scope: tokens.scope,
349
+ ...fields,
350
+ // create() requires accessTokenEncrypted as non-optional in
351
+ // the type — buildAuthFields always sets it, so the spread
352
+ // satisfies the contract.
353
+ accessTokenEncrypted: fields.accessTokenEncrypted,
326
354
  isActive: true,
327
355
  });
328
356
  }
329
357
  }
358
+ /**
359
+ * Tenant-scoped upsert. Same shape as `upsertAuth` but writes a
360
+ * row tagged with `tenantId` + `connectedByUserId`, hitting the
361
+ * tenant uniqueness lane on `(tenantId, connectorId)`.
362
+ *
363
+ * `connectedByUserId` is the audit-grade record of which human
364
+ * clicked Connect for the workspace; the UI surfaces it as
365
+ * "Connected by [Admin Name]" so other admins know who attached
366
+ * each integration. We default it to `userId` if the caller didn't
367
+ * pass one — they're the same thing in almost every case.
368
+ */
369
+ async upsertAuthForTenant(tenantId, connectorId, tokens, opts) {
370
+ const existing = await this.deps.authRepo.findByTenantAndConnector(tenantId, connectorId);
371
+ const fields = this.buildAuthFields(tokens);
372
+ const connectedByUserId = opts.connectedByUserId ?? opts.userId;
373
+ if (existing) {
374
+ await this.deps.authRepo.update(existing.id, {
375
+ ...fields,
376
+ isActive: true,
377
+ // Refresh the audit field on re-consent so "Connected by"
378
+ // reflects who last attached the integration.
379
+ connectedByUserId,
380
+ ...(opts.accountLabel ? { accountLabel: opts.accountLabel } : {}),
381
+ });
382
+ }
383
+ else {
384
+ await this.deps.authRepo.create({
385
+ id: (0, crypto_1.randomUUID)(),
386
+ userId: opts.userId,
387
+ tenantId,
388
+ connectedByUserId,
389
+ connectorId,
390
+ ...fields,
391
+ accessTokenEncrypted: fields.accessTokenEncrypted,
392
+ accountLabel: opts.accountLabel,
393
+ isActive: true,
394
+ });
395
+ }
396
+ }
397
+ /**
398
+ * Encrypt tokens and compute `expiresAt`. Shared by both the
399
+ * user-scoped and tenant-scoped upsert paths. Pulled into a helper
400
+ * so a Google quirk ("doesn't return refresh_token on re-consent")
401
+ * has a single owner — both paths handle the missing-refresh-token
402
+ * case the same way.
403
+ */
404
+ buildAuthFields(tokens) {
405
+ const expiresAt = tokens.expiresIn
406
+ ? new Date(Date.now() + tokens.expiresIn * 1000)
407
+ : undefined;
408
+ const accessTokenEncrypted = this.deps.cipher.encrypt(tokens.accessToken);
409
+ const refreshTokenEncrypted = tokens.refreshToken
410
+ ? this.deps.cipher.encrypt(tokens.refreshToken)
411
+ : undefined;
412
+ return {
413
+ accessTokenEncrypted,
414
+ // Some providers (Google) don't return a refresh_token on
415
+ // re-consent — keep the old one in that case by leaving the
416
+ // field undefined so update() doesn't null it out.
417
+ ...(refreshTokenEncrypted ? { refreshTokenEncrypted } : {}),
418
+ expiresAt,
419
+ scope: tokens.scope,
420
+ };
421
+ }
330
422
  async getAccessToken(a) {
331
423
  const skewMs = this.refreshSkewSeconds * 1000;
332
424
  const stillValid = !a.expiresAt || a.expiresAt.getTime() - Date.now() > skewMs;
@@ -1,4 +1,4 @@
1
- import type { AgentResponse, AnthropicMessage, StreamChunk, SubAgentDelegation, ToolExecutionContext } from '../types/agent.types';
1
+ import type { AgentResponse, AgentToolDefinition, AnthropicMessage, StreamChunk, SubAgentDelegation, ToolExecutionContext } from '../types/agent.types';
2
2
  import type { AgentDefinition, AnthropicConfig } from '../types/config.types';
3
3
  import type { AgentRunnerService } from './agent-runner.service';
4
4
  import type { Logger } from './tool-registry.service';
@@ -26,6 +26,23 @@ export interface OrchestratorServiceOptions {
26
26
  * `AgentConfigService` + `toAgentDefinition` adapter in.
27
27
  */
28
28
  resolveAgent?(agentId: string): Promise<AgentDefinition | null> | AgentDefinition | null;
29
+ /**
30
+ * Resolve the connector-derived `extraTools` for a sub-agent given
31
+ * the caller's userId. The orchestrator forwards these to the inner
32
+ * runner / recursive stream so connector tools (Gmail, ClickUp,
33
+ * MCP servers, etc.) work the same when an agent is reached via a
34
+ * delegation as when it is reached directly.
35
+ *
36
+ * Without this hook, a sub-agent invoked through an orchestrator
37
+ * would lose every connector tool it normally has — same bug a
38
+ * non-orchestrator agent would hit if its host forgot to pass
39
+ * `extraTools` into `runner.stream`.
40
+ *
41
+ * Returning `undefined` is fine (treated as "no extra tools"); the
42
+ * orchestrator still works, but the sub-agent runs with the static
43
+ * tool list only.
44
+ */
45
+ resolveExtraTools?(agentId: string, userId: string): Promise<AgentToolDefinition[] | undefined> | AgentToolDefinition[] | undefined;
29
46
  }
30
47
  /**
31
48
  * Multi-agent workflows. Orchestrator agents can delegate tasks to specialized
@@ -43,6 +60,7 @@ export declare class OrchestratorService {
43
60
  private readonly client;
44
61
  private readonly logger;
45
62
  private readonly resolveAgentHook?;
63
+ private resolveExtraToolsHook?;
46
64
  constructor(anthropicConfig: AnthropicConfig, runner: AgentRunnerService, opts: OrchestratorServiceOptions);
47
65
  /**
48
66
  * Lookup with dynamic-resolver fallback. Hits the static map first
@@ -53,6 +71,17 @@ export declare class OrchestratorService {
53
71
  * for the lifetime of the service to avoid re-fetching across a
54
72
  * multi-turn conversation.
55
73
  */
74
+ /** Late-bind the extraTools resolver. Used by `AgentService` (which
75
+ * owns the connector registry) to expose its per-agent extraTools
76
+ * resolution to the orchestrator without a circular constructor
77
+ * dependency. Calling this with a no-op resolver is also fine
78
+ * (treated the same as never wiring it). */
79
+ setExtraToolsResolver(hook: OrchestratorServiceOptions['resolveExtraTools']): void;
80
+ /** Resolve connector-derived extraTools for a sub-agent. Returns
81
+ * undefined when no hook is wired or no userId is in context — the
82
+ * caller treats both as "no extras", which preserves the legacy
83
+ * behaviour for hosts that haven't wired the hook yet. */
84
+ private resolveSubAgentExtraTools;
56
85
  private resolveAgentDynamic;
57
86
  /**
58
87
  * Run an agent. Orchestrators automatically get delegation tools injected.
@@ -40,6 +40,7 @@ class OrchestratorService {
40
40
  });
41
41
  this.logger = opts.logger ?? noopLogger;
42
42
  this.resolveAgentHook = opts.resolveAgent;
43
+ this.resolveExtraToolsHook = opts.resolveExtraTools;
43
44
  for (const agent of opts.agents) {
44
45
  this.agentsMap.set(agent.id, agent);
45
46
  }
@@ -53,6 +54,35 @@ class OrchestratorService {
53
54
  * for the lifetime of the service to avoid re-fetching across a
54
55
  * multi-turn conversation.
55
56
  */
57
+ /** Late-bind the extraTools resolver. Used by `AgentService` (which
58
+ * owns the connector registry) to expose its per-agent extraTools
59
+ * resolution to the orchestrator without a circular constructor
60
+ * dependency. Calling this with a no-op resolver is also fine
61
+ * (treated the same as never wiring it). */
62
+ setExtraToolsResolver(hook) {
63
+ this.resolveExtraToolsHook = hook;
64
+ }
65
+ /** Resolve connector-derived extraTools for a sub-agent. Returns
66
+ * undefined when no hook is wired or no userId is in context — the
67
+ * caller treats both as "no extras", which preserves the legacy
68
+ * behaviour for hosts that haven't wired the hook yet. */
69
+ async resolveSubAgentExtraTools(agentId, context) {
70
+ if (!this.resolveExtraToolsHook)
71
+ return undefined;
72
+ const userId = context.userId;
73
+ if (!userId)
74
+ return undefined;
75
+ try {
76
+ const tools = await this.resolveExtraToolsHook(agentId, userId);
77
+ return tools && tools.length > 0 ? tools : undefined;
78
+ }
79
+ catch (err) {
80
+ // A failed connector lookup shouldn't strand the sub-agent — log
81
+ // and let it run with its static tools only.
82
+ this.logger.warn(`resolveExtraTools failed for sub-agent "${agentId}": ${err.message}`);
83
+ return undefined;
84
+ }
85
+ }
56
86
  async resolveAgentDynamic(agentId) {
57
87
  const cached = this.agentsMap.get(agentId);
58
88
  if (cached)
@@ -72,7 +102,12 @@ class OrchestratorService {
72
102
  async run(agentId, messages, context) {
73
103
  const agent = this.getAgent(agentId);
74
104
  if (!agent.canOrchestrate || !agent.subAgents?.length) {
75
- return this.runner.run(agent, messages, context);
105
+ // Resolve the agent's connector-derived extraTools and forward
106
+ // them — otherwise sub-agents invoked through the orchestrator
107
+ // lose every connector tool they normally have when reached
108
+ // directly (Gmail, ClickUp, MCP servers, etc.).
109
+ const extraTools = await this.resolveSubAgentExtraTools(agent.id, context);
110
+ return this.runner.run(agent, messages, context, extraTools ? { extraTools } : undefined);
76
111
  }
77
112
  this.logger.debug(`Running orchestrator "${agentId}" with subagents: ${agent.subAgents.join(', ')}`);
78
113
  return this.runOrchestratorLoop(agent, messages, context);
@@ -100,7 +135,10 @@ class OrchestratorService {
100
135
  // No-op orchestration — fall straight through. The runner's
101
136
  // chunks won't carry `actingAgentId`, which is exactly what we
102
137
  // want: this conversation is bound to one agent.
103
- yield* this.runner.stream(agent, messages, context);
138
+ // Resolve extraTools so a sub-agent invoked via delegation has
139
+ // the same connector toolset it would have on a direct call.
140
+ const extraTools = await this.resolveSubAgentExtraTools(agent.id, context);
141
+ yield* this.runner.stream(agent, messages, context, extraTools ? { extraTools } : undefined);
104
142
  return;
105
143
  }
106
144
  this.logger.debug(`Streaming orchestrator "${agentId}" with subagents: ${agent.subAgents.join(', ')}`);
@@ -218,7 +256,13 @@ class OrchestratorService {
218
256
  outputTokens: 0,
219
257
  totalTokens: 0,
220
258
  };
221
- for await (const chunk of this.runner.stream(subAgent, subMessages, {
259
+ // Recurse via `this.stream` (NOT `this.runner.stream`) so a
260
+ // sub-agent that is itself an orchestrator gets ITS delegation
261
+ // tools synthesized. Standalone sub-agents short-circuit to
262
+ // the runner inside `this.stream`. Same fix as the non-stream
263
+ // path above — without it, hierarchical mode silently fell
264
+ // through to plain runner at depth > 1.
265
+ for await (const chunk of this.stream(subAgentId, subMessages, {
222
266
  ...context,
223
267
  agentId: subAgentId,
224
268
  })) {
@@ -317,9 +361,16 @@ class OrchestratorService {
317
361
  const { task } = block.input;
318
362
  const { subAgentId } = delegateTool;
319
363
  this.logger.log(`Orchestrator "${orchestrator.id}" → "${subAgentId}": ${task.slice(0, 80)}...`);
320
- const subAgent = this.getAgent(subAgentId);
321
364
  const subMessages = [{ role: 'user', content: task }];
322
- const subResult = await this.runner.run(subAgent, subMessages, {
365
+ // Recurse via `this.run` (NOT `this.runner.run`) so a
366
+ // sub-agent that is itself an orchestrator gets ITS delegation
367
+ // tools synthesized as well. Standalone sub-agents fall
368
+ // through to the runner inside `this.run`. Without this, the
369
+ // hierarchical mode broke at depth > 1 — an intermediate node
370
+ // like ChatAI saw `canOrchestrate=true` in DB but the runtime
371
+ // never honoured it because it was being entered through the
372
+ // plain runner.
373
+ const subResult = await this.run(subAgentId, subMessages, {
323
374
  ...context,
324
375
  agentId: subAgentId,
325
376
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
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",