@agentforge-io/core 2.0.8 → 2.0.11

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.
@@ -2,6 +2,7 @@ import type { AgentOverrides, AgentResponse, AnthropicMessage, StreamChunk, Tool
2
2
  import type { AgentDefinition, AnthropicConfig } from '../types/config.types';
3
3
  import type { ToolRegistryService } from './tool-registry.service';
4
4
  import type { Logger } from './tool-registry.service';
5
+ import { type ToolApprovalGate } from './tool-approval-gate';
5
6
  /**
6
7
  * Framework-free runner for Claude. Handles the agentic loop (tool calls) for
7
8
  * sync runs and exposes streaming as an `AsyncGenerator<StreamChunk>` so any
@@ -12,8 +13,23 @@ export declare class AgentRunnerService {
12
13
  private readonly toolRegistry;
13
14
  private readonly client;
14
15
  private readonly logger;
16
+ /**
17
+ * Optional pre-dispatch gate. When supplied, every tool call passes
18
+ * through `check()` before the actual handler runs. The gate may:
19
+ * - return `allow` → execution proceeds (identical to no-gate path)
20
+ * - return `blocked` → runner throws ToolBlockedError; loop bubbles up
21
+ * - return `approval` → runner throws ToolApprovalRequired; the
22
+ * consumer is responsible for catching, persisting state, and
23
+ * resuming later (typically by re-running the turn after the
24
+ * human approves).
25
+ *
26
+ * Left undefined by default to keep deployments without an approval
27
+ * story behaviorally identical to the pre-gate codebase.
28
+ */
29
+ private readonly approvalGate;
15
30
  constructor(anthropicConfig: AnthropicConfig, toolRegistry: ToolRegistryService, opts?: {
16
31
  logger?: Logger;
32
+ approvalGate?: ToolApprovalGate;
17
33
  });
18
34
  run(agent: AgentDefinition, messages: AnthropicMessage[], context: ToolExecutionContext, overrides?: AgentOverrides): Promise<AgentResponse>;
19
35
  stream(agent: AgentDefinition, messages: AnthropicMessage[], context: ToolExecutionContext, overrides?: AgentOverrides): AsyncGenerator<StreamChunk>;
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AgentRunnerService = void 0;
7
7
  const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
8
8
  const crypto_1 = require("crypto");
9
+ const tool_approval_gate_1 = require("./tool-approval-gate");
9
10
  const noopLogger = {
10
11
  log: () => { }, warn: () => { }, debug: () => { }, error: () => { },
11
12
  };
@@ -23,6 +24,7 @@ class AgentRunnerService {
23
24
  baseURL: anthropicConfig.baseURL,
24
25
  });
25
26
  this.logger = opts.logger ?? noopLogger;
27
+ this.approvalGate = opts.approvalGate;
26
28
  }
27
29
  // ─── Run (non-streaming) ──────────────────────────────────────────────────
28
30
  async run(agent, messages, context, overrides) {
@@ -68,6 +70,17 @@ class AgentRunnerService {
68
70
  output = await this.dispatchTool(block.name, block.input, context, extras);
69
71
  }
70
72
  catch (err) {
73
+ // Approval-gate signals are NOT tool execution errors —
74
+ // they ARE the surface the caller of run() branches on.
75
+ // Re-throw so the loop aborts and the consumer (executor /
76
+ // conversation service) can persist the pause + surface
77
+ // the approvalId. Without this re-throw, the runner would
78
+ // feed back a `"Error executing tool …"` to the LLM,
79
+ // hiding the pause behind a regular tool failure.
80
+ if (err instanceof tool_approval_gate_1.ToolApprovalRequired ||
81
+ err instanceof tool_approval_gate_1.ToolBlockedError) {
82
+ throw err;
83
+ }
71
84
  error = err instanceof Error ? err.message : String(err);
72
85
  output = `Error executing tool ${block.name}: ${error}`;
73
86
  }
@@ -165,6 +178,15 @@ class AgentRunnerService {
165
178
  output = await this.dispatchTool(block.name, block.input, context, extras);
166
179
  }
167
180
  catch (err) {
181
+ // Same rule as the sync path: gate signals propagate, regular
182
+ // errors collapse into a `"Error: …"` tool result the LLM
183
+ // can react to. The consumer of `stream()` catches the
184
+ // ToolApprovalRequired and decides whether to emit a
185
+ // structured chunk to the client or just end the stream.
186
+ if (err instanceof tool_approval_gate_1.ToolApprovalRequired ||
187
+ err instanceof tool_approval_gate_1.ToolBlockedError) {
188
+ throw err;
189
+ }
168
190
  output = `Error: ${err instanceof Error ? err.message : String(err)}`;
169
191
  }
170
192
  yield { type: 'tool_result', toolName: block.name, result: output };
@@ -261,6 +283,27 @@ class AgentRunnerService {
261
283
  return { tools: tools.length ? tools : undefined, extras };
262
284
  }
263
285
  async dispatchTool(name, input, context, extras) {
286
+ // Consult the approval gate before any tool work happens. The throws
287
+ // here are caught and re-raised verbatim by `run()` / `stream()` —
288
+ // they ARE the surface the consumer branches on, not generic errors.
289
+ if (this.approvalGate) {
290
+ const decision = await this.approvalGate.check({
291
+ toolName: name,
292
+ toolInput: input,
293
+ context,
294
+ });
295
+ if (decision.kind === 'blocked') {
296
+ throw new tool_approval_gate_1.ToolBlockedError(name, decision.reason);
297
+ }
298
+ if (decision.kind === 'approval') {
299
+ throw new tool_approval_gate_1.ToolApprovalRequired({
300
+ approvalId: decision.approvalId,
301
+ toolName: name,
302
+ expiresAt: decision.expiresAt,
303
+ });
304
+ }
305
+ // `allow` falls through to the normal dispatch path.
306
+ }
264
307
  const extra = extras.get(name);
265
308
  if (extra)
266
309
  return extra.execute(input, context);
@@ -96,17 +96,27 @@ class AgentService {
96
96
  * Returns the runtime `AgentDefinition` ready to feed the runner.
97
97
  */
98
98
  async resolveAgent(params) {
99
+ // Public-chat slug lookup MUST gate on isActive — a deactivated
100
+ // agent shouldn't be reachable from a customer-facing widget.
99
101
  if (params.agentSlug && params.tenantId && this.resolver) {
100
102
  const record = await this.resolver.findBySlug(params.tenantId, params.agentSlug);
101
103
  if (record && record.isActive)
102
104
  return toAgentDefinition(record);
103
105
  }
106
+ // Direct-id lookup does NOT gate on isActive. The caller has the
107
+ // opaque uuid because they already created a conversation against
108
+ // that agent (admin UI, system Agent Assist seed, internal job…).
109
+ // Refusing to stream the next turn just because someone toggled
110
+ // the agent inactive between turns surfaces as the opaque
111
+ // "Agent <uuid> not found" the admin saw in prod — actively
112
+ // hostile to operators. Leave inactive-filtering to the layer
113
+ // that handed out the agentId in the first place.
104
114
  if (params.agentId) {
105
115
  if (this.resolver) {
106
116
  const record = await this.resolver
107
117
  .findById(params.agentId)
108
118
  .catch(() => null);
109
- if (record && record.isActive)
119
+ if (record)
110
120
  return toAgentDefinition(record);
111
121
  }
112
122
  // Final fallback: hardcoded SDK array.
@@ -1,5 +1,6 @@
1
1
  export { ToolRegistryService, type Logger, type ToolDescription, } from './tool-registry.service';
2
2
  export { AgentRunnerService } from './agent-runner.service';
3
+ export { ToolApprovalRequired, ToolBlockedError, isToolApprovalRequired, isToolBlockedError, type ToolApprovalGate, type ToolApprovalCheckArgs, type ToolApprovalDecision, } from './tool-approval-gate';
3
4
  export { PreparedStreamService, PreparedStreamError, } from './prepared-stream.service';
4
5
  export { InMemoryPreparedStreamStore } from './in-memory-prepared-stream.store';
5
6
  export { ConversationService, ConversationNotFoundError, } from './conversation.service';
@@ -1,12 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.InMemoryAuthorizeStateStore = exports.ConnectorError = exports.ConnectorRegistryService = exports.OAuth2Service = exports.McpServerError = exports.McpServerService = exports.McpClientError = exports.McpClientService = exports.ChatTokenError = exports.ChatTokenService = exports.AgentJobWorker = exports.OrchestratorError = exports.OrchestratorService = exports.AgentForbiddenError = exports.AgentService = exports.ConversationNotFoundError = exports.ConversationService = exports.InMemoryPreparedStreamStore = exports.PreparedStreamError = exports.PreparedStreamService = exports.AgentRunnerService = exports.ToolRegistryService = void 0;
3
+ exports.InMemoryAuthorizeStateStore = exports.ConnectorError = exports.ConnectorRegistryService = exports.OAuth2Service = exports.McpServerError = exports.McpServerService = exports.McpClientError = exports.McpClientService = exports.ChatTokenError = exports.ChatTokenService = exports.AgentJobWorker = exports.OrchestratorError = exports.OrchestratorService = exports.AgentForbiddenError = exports.AgentService = exports.ConversationNotFoundError = exports.ConversationService = exports.InMemoryPreparedStreamStore = exports.PreparedStreamError = exports.PreparedStreamService = exports.isToolBlockedError = exports.isToolApprovalRequired = exports.ToolBlockedError = exports.ToolApprovalRequired = exports.AgentRunnerService = exports.ToolRegistryService = void 0;
4
4
  // Public service surface of @agentforge-io/core. AI runtime only — auth,
5
5
  // identity, billing, infra, etc. have all moved to the host server.
6
6
  var tool_registry_service_1 = require("./tool-registry.service");
7
7
  Object.defineProperty(exports, "ToolRegistryService", { enumerable: true, get: function () { return tool_registry_service_1.ToolRegistryService; } });
8
8
  var agent_runner_service_1 = require("./agent-runner.service");
9
9
  Object.defineProperty(exports, "AgentRunnerService", { enumerable: true, get: function () { return agent_runner_service_1.AgentRunnerService; } });
10
+ // Approval gate primitives. Optional dependency of the runner — hosts
11
+ // that don't need human-in-the-loop tool calls leave it unwired and
12
+ // nothing changes. See @agentforge-io/server's ApprovalGateAdapter for
13
+ // a reference impl.
14
+ var tool_approval_gate_1 = require("./tool-approval-gate");
15
+ Object.defineProperty(exports, "ToolApprovalRequired", { enumerable: true, get: function () { return tool_approval_gate_1.ToolApprovalRequired; } });
16
+ Object.defineProperty(exports, "ToolBlockedError", { enumerable: true, get: function () { return tool_approval_gate_1.ToolBlockedError; } });
17
+ Object.defineProperty(exports, "isToolApprovalRequired", { enumerable: true, get: function () { return tool_approval_gate_1.isToolApprovalRequired; } });
18
+ Object.defineProperty(exports, "isToolBlockedError", { enumerable: true, get: function () { return tool_approval_gate_1.isToolBlockedError; } });
10
19
  var prepared_stream_service_1 = require("./prepared-stream.service");
11
20
  Object.defineProperty(exports, "PreparedStreamService", { enumerable: true, get: function () { return prepared_stream_service_1.PreparedStreamService; } });
12
21
  Object.defineProperty(exports, "PreparedStreamError", { enumerable: true, get: function () { return prepared_stream_service_1.PreparedStreamError; } });
@@ -19,6 +19,23 @@ export interface OAuth2ProviderConfig {
19
19
  /** PKCE: defaults to true for security, but providers that don't support
20
20
  * it (rare in 2026) can opt out. */
21
21
  usePkce?: boolean;
22
+ /**
23
+ * Optional extractor for non-standard token response shapes. The default
24
+ * reads `access_token` / `refresh_token` / etc. directly off the JSON
25
+ * body — RFC 6749 compliant providers (Google, HubSpot, ClickUp) don't
26
+ * need this.
27
+ *
28
+ * Slack's `oauth.v2.access` is the notable exception: when the consent
29
+ * grants only user scopes, the user token lands at
30
+ * `json.authed_user.access_token`, not at the root. Connectors with
31
+ * non-standard envelopes provide this hook to map their response into
32
+ * the canonical `TokenSet` the registry persists.
33
+ *
34
+ * If the extractor returns `accessToken: ''` (or any falsy string), the
35
+ * service treats the exchange as failed and surfaces a clear error
36
+ * instead of letting an empty string poison the encrypted-token row.
37
+ */
38
+ tokenExtractor?: (json: unknown) => TokenSet;
22
39
  }
23
40
  export interface AuthorizeUrlResult {
24
41
  url: string;
@@ -45,7 +45,7 @@ class OAuth2Service {
45
45
  });
46
46
  if (pkceVerifier)
47
47
  body.set('code_verifier', pkceVerifier);
48
- return this.postToken(cfg.tokenUrl, body);
48
+ return this.postToken(cfg.tokenUrl, body, cfg.tokenExtractor);
49
49
  }
50
50
  async refresh(cfg, refreshToken) {
51
51
  const body = new URLSearchParams({
@@ -54,9 +54,9 @@ class OAuth2Service {
54
54
  client_id: cfg.clientId,
55
55
  client_secret: cfg.clientSecret,
56
56
  });
57
- return this.postToken(cfg.tokenUrl, body);
57
+ return this.postToken(cfg.tokenUrl, body, cfg.tokenExtractor);
58
58
  }
59
- async postToken(tokenUrl, body) {
59
+ async postToken(tokenUrl, body, extractor) {
60
60
  const res = await this.fetchImpl(tokenUrl, {
61
61
  method: 'POST',
62
62
  headers: {
@@ -70,13 +70,30 @@ class OAuth2Service {
70
70
  throw new Error(`OAuth2 token endpoint ${tokenUrl} returned ${res.status}: ${text}`);
71
71
  }
72
72
  const json = (await res.json());
73
- return {
74
- accessToken: json.access_token,
75
- refreshToken: json.refresh_token,
76
- expiresIn: json.expires_in,
77
- scope: json.scope,
78
- tokenType: json.token_type,
79
- };
73
+ const tokens = extractor ? extractor(json) : defaultExtractor(json);
74
+ // Guard against accidentally persisting an empty-string token — that
75
+ // would crash the cipher with "Received undefined" downstream and the
76
+ // operator would chase a confusing stack trace. Better to fail fast
77
+ // here with a descriptive error so the connector author knows their
78
+ // extractor (or the default) missed the token.
79
+ if (!tokens.accessToken) {
80
+ throw new Error(`OAuth2 token endpoint ${tokenUrl} returned a response without an access token. ` +
81
+ 'If the provider uses a non-standard envelope, supply `tokenExtractor` on the ConnectorDefinition.');
82
+ }
83
+ return tokens;
80
84
  }
81
85
  }
82
86
  exports.OAuth2Service = OAuth2Service;
87
+ /** RFC 6749 standard shape — used when the connector doesn't supply a
88
+ * custom extractor. Mirrors the original behavior before the
89
+ * `tokenExtractor` hook was added. */
90
+ function defaultExtractor(json) {
91
+ const j = json;
92
+ return {
93
+ accessToken: j.access_token ?? '',
94
+ refreshToken: j.refresh_token,
95
+ expiresIn: j.expires_in,
96
+ scope: j.scope,
97
+ tokenType: j.token_type,
98
+ };
99
+ }
@@ -0,0 +1,97 @@
1
+ import type { ToolExecutionContext } from '../types/agent.types';
2
+ /**
3
+ * Optional pre-dispatch gate consulted by `AgentRunnerService` before
4
+ * every tool call. The runner is intentionally agnostic about WHERE the
5
+ * decision comes from — the host (e.g. AgentForge platform) supplies a
6
+ * concrete implementation backed by its own DB / config.
7
+ *
8
+ * Three possible decisions:
9
+ *
10
+ * - `allow` → runner proceeds to invoke the tool. Identical to the
11
+ * pre-gate behavior, so a deployment without a gate
12
+ * installed is a no-op.
13
+ * - `blocked` → runner throws `ToolBlockedError`. In a healthy
14
+ * deployment a blocked tool isn't even injected into
15
+ * the LLM's tool list, so this branch is defense-in-
16
+ * depth against a stale agent definition the model
17
+ * somehow still references.
18
+ * - `approval` → runner throws `ToolApprovalRequired`. The caller of
19
+ * `run()` / `stream()` is expected to catch this,
20
+ * persist whatever it needs to resume later, and
21
+ * surface the approval id to the end consumer.
22
+ *
23
+ * The async signature is deliberate — every realistic adapter will need
24
+ * at least one DB read (look up the tool's mode on the agent) and often
25
+ * one write (open the approval row).
26
+ */
27
+ export interface ToolApprovalGate {
28
+ check(args: ToolApprovalCheckArgs): Promise<ToolApprovalDecision>;
29
+ }
30
+ export interface ToolApprovalCheckArgs {
31
+ /** The tool name as the LLM emitted it (post-prefix for connector +
32
+ * MCP tools — same string the runner uses to look the tool up in
33
+ * the registry). */
34
+ toolName: string;
35
+ /** Tool arguments the LLM produced. The gate may persist these on a
36
+ * pending approval row so a human can inspect; the runner never
37
+ * mutates them after the gate returns. */
38
+ toolInput: Record<string, unknown>;
39
+ /** Same context object passed to the tool's `execute`. Carries the
40
+ * agent / conversation / user identities the adapter needs to scope
41
+ * its lookups. */
42
+ context: ToolExecutionContext;
43
+ }
44
+ export type ToolApprovalDecision = {
45
+ kind: 'allow';
46
+ } | {
47
+ kind: 'blocked';
48
+ reason?: string;
49
+ } | {
50
+ kind: 'approval';
51
+ /** Opaque id of the approval row the gate just created. The
52
+ * consumer surfaces this to the operator so they can find the
53
+ * row in the approvals queue. */
54
+ approvalId: string;
55
+ /** ISO timestamp at which the approval will auto-expire. Useful
56
+ * for the consumer to set a comparable timeout on its own
57
+ * side (e.g. expire a paused conversation). */
58
+ expiresAt: string;
59
+ };
60
+ /**
61
+ * Thrown by the runner when the gate returns `approval`. The caller is
62
+ * expected to catch this exact class (not a plain `Error`) and translate
63
+ * it to its surface: an automation run row flips to `awaiting_approval`,
64
+ * a conversation appends a synthetic message, a public chat stream
65
+ * emits a structured chunk, etc.
66
+ *
67
+ * Carries the approval id + expiry so the surface can echo them back to
68
+ * the consumer without an extra DB read.
69
+ */
70
+ export declare class ToolApprovalRequired extends Error {
71
+ readonly approvalId: string;
72
+ readonly toolName: string;
73
+ readonly expiresAt: string;
74
+ constructor(args: {
75
+ approvalId: string;
76
+ toolName: string;
77
+ expiresAt: string;
78
+ });
79
+ }
80
+ /**
81
+ * Thrown by the runner when the gate returns `blocked`. Different class
82
+ * than `ToolApprovalRequired` so callers can branch — `awaiting_approval`
83
+ * is a pause, `blocked` is a hard refusal.
84
+ */
85
+ export declare class ToolBlockedError extends Error {
86
+ readonly toolName: string;
87
+ readonly reason?: string;
88
+ constructor(toolName: string, reason?: string);
89
+ }
90
+ /**
91
+ * Cheap helpers for catch-blocks that want to know whether a thrown
92
+ * error escaped from the gate path. Using these avoids `instanceof`
93
+ * realm pitfalls when the lib's compiled code is imported by both the
94
+ * platform's source tree and a vendored copy in tests.
95
+ */
96
+ export declare function isToolApprovalRequired(err: unknown): err is ToolApprovalRequired;
97
+ export declare function isToolBlockedError(err: unknown): err is ToolBlockedError;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ToolBlockedError = exports.ToolApprovalRequired = void 0;
4
+ exports.isToolApprovalRequired = isToolApprovalRequired;
5
+ exports.isToolBlockedError = isToolBlockedError;
6
+ /**
7
+ * Thrown by the runner when the gate returns `approval`. The caller is
8
+ * expected to catch this exact class (not a plain `Error`) and translate
9
+ * it to its surface: an automation run row flips to `awaiting_approval`,
10
+ * a conversation appends a synthetic message, a public chat stream
11
+ * emits a structured chunk, etc.
12
+ *
13
+ * Carries the approval id + expiry so the surface can echo them back to
14
+ * the consumer without an extra DB read.
15
+ */
16
+ class ToolApprovalRequired extends Error {
17
+ constructor(args) {
18
+ super(`Tool "${args.toolName}" requires approval (id ${args.approvalId}); ` +
19
+ `decision needed before ${args.expiresAt}`);
20
+ this.name = 'ToolApprovalRequired';
21
+ this.approvalId = args.approvalId;
22
+ this.toolName = args.toolName;
23
+ this.expiresAt = args.expiresAt;
24
+ }
25
+ }
26
+ exports.ToolApprovalRequired = ToolApprovalRequired;
27
+ /**
28
+ * Thrown by the runner when the gate returns `blocked`. Different class
29
+ * than `ToolApprovalRequired` so callers can branch — `awaiting_approval`
30
+ * is a pause, `blocked` is a hard refusal.
31
+ */
32
+ class ToolBlockedError extends Error {
33
+ constructor(toolName, reason) {
34
+ super(reason ?? `Tool "${toolName}" is blocked for this agent`);
35
+ this.name = 'ToolBlockedError';
36
+ this.toolName = toolName;
37
+ this.reason = reason;
38
+ }
39
+ }
40
+ exports.ToolBlockedError = ToolBlockedError;
41
+ /**
42
+ * Cheap helpers for catch-blocks that want to know whether a thrown
43
+ * error escaped from the gate path. Using these avoids `instanceof`
44
+ * realm pitfalls when the lib's compiled code is imported by both the
45
+ * platform's source tree and a vendored copy in tests.
46
+ */
47
+ function isToolApprovalRequired(err) {
48
+ return (err instanceof ToolApprovalRequired ||
49
+ (typeof err === 'object' &&
50
+ err !== null &&
51
+ err.name === 'ToolApprovalRequired'));
52
+ }
53
+ function isToolBlockedError(err) {
54
+ return (err instanceof ToolBlockedError ||
55
+ (typeof err === 'object' &&
56
+ err !== null &&
57
+ err.name === 'ToolBlockedError'));
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.0.8",
3
+ "version": "2.0.11",
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",