@agentforge-io/core 2.1.1 → 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.
- package/dist/domain/connector-auth.d.ts +47 -8
- package/dist/repositories/index.d.ts +14 -3
- package/dist/services/agent.service.d.ts +34 -4
- package/dist/services/agent.service.js +101 -14
- package/dist/services/connector-registry.service.d.ts +50 -2
- package/dist/services/connector-registry.service.js +115 -23
- package/dist/services/orchestrator.service.d.ts +30 -1
- package/dist/services/orchestrator.service.js +56 -5
- package/package.json +1 -1
|
@@ -1,19 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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).
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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(
|
|
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(
|
|
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
|
|
217
|
-
//
|
|
218
|
-
|
|
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 —
|
|
289
|
-
//
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|