@stackfactor/agent-utils 1.0.23 → 1.1.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.
@@ -2,8 +2,12 @@ import constants from "./const.js";
2
2
  import errorHandling from "./errorHandling.js";
3
3
  import langChain from "./langChain.js";
4
4
  import logger from "./logger.js";
5
+ import { serve } from "./serve.js";
6
+ import * as runtimeContext from "./runtimeContext.js";
5
7
  export { constants };
6
8
  export { errorHandling };
7
9
  export { langChain };
8
10
  export { logger };
11
+ export { serve };
12
+ export { runtimeContext };
9
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAC/C,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,CAAC;AAEzB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,MAAM,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAC/C,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,KAAK,cAAc,MAAM,qBAAqB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,CAAC;AAEzB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,OAAO,EAAE,KAAK,EAAE,CAAC;AAEjB,OAAO,EAAE,cAAc,EAAE,CAAC"}
package/dist/cjs/index.js CHANGED
@@ -1,9 +1,42 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.logger = exports.langChain = exports.errorHandling = exports.constants = void 0;
39
+ exports.runtimeContext = exports.serve = exports.logger = exports.langChain = exports.errorHandling = exports.constants = void 0;
7
40
  const const_js_1 = __importDefault(require("./const.js"));
8
41
  exports.constants = const_js_1.default;
9
42
  const errorHandling_js_1 = __importDefault(require("./errorHandling.js"));
@@ -12,3 +45,7 @@ const langChain_js_1 = __importDefault(require("./langChain.js"));
12
45
  exports.langChain = langChain_js_1.default;
13
46
  const logger_js_1 = __importDefault(require("./logger.js"));
14
47
  exports.logger = logger_js_1.default;
48
+ const serve_js_1 = require("./serve.js");
49
+ Object.defineProperty(exports, "serve", { enumerable: true, get: function () { return serve_js_1.serve; } });
50
+ const runtimeContext = __importStar(require("./runtimeContext.js"));
51
+ exports.runtimeContext = runtimeContext;
@@ -5,6 +5,7 @@ declare const _default: {
5
5
  runPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any, minPercent?: number, maxPercent?: number, expectsJsonResponse?: boolean, schema?: any, agentName?: string, tools?: any[]) => Promise<any>;
6
6
  runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any) => Promise<any>;
7
7
  throwErrorIfNotSuccessful: (response: any) => string;
8
+ validateModel: (selectedModel: string, supportedModels: string[]) => string;
8
9
  };
9
10
  export default _default;
10
11
  //# sourceMappingURL=langChain.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";0CAgD6C,GAAG,KAAG,IAAI;wBAke/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;oCAqWF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDA6rBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA18B8B,GAAG,KAAG,MAAM;;AAqgCzD,wBAOE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";0CAiD6C,GAAG,KAAG,IAAI;wBAke/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;oCAuXF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDAwsBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CAv+B8B,GAAG,KAAG,MAAM;mCAoBxC,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AA4gCT,wBAQE"}
@@ -13,6 +13,7 @@ const langchain_1 = require("langchain");
13
13
  const errorHandling_js_1 = __importDefault(require("./errorHandling.js"));
14
14
  const logger_js_1 = __importDefault(require("./logger.js"));
15
15
  const zod_to_json_schema_1 = require("zod-to-json-schema");
16
+ const runtimeContext_js_1 = require("./runtimeContext.js");
16
17
  const JSON_ESCAPE_INSTRUCTION = `
17
18
  CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
18
19
  - Newlines → \\n
@@ -589,6 +590,20 @@ const throwErrorIfNotSuccessful = (response) => {
589
590
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, const_js_1.default.ERROR.UNABLE_TO_GENERATE_CONTENT);
590
591
  }
591
592
  };
593
+ /**
594
+ * Returns `selectedModel` when it appears in `supportedModels`, otherwise returns
595
+ * the first entry in `supportedModels` as a safe fallback. Use this to coerce a
596
+ * caller-supplied model identifier to one the current integration is known to
597
+ * support, instead of failing or silently passing an unsupported model to
598
+ * `getLLMModel`. When `supportedModels` is empty there is no fallback to pick,
599
+ * so the original `selectedModel` is returned unchanged.
600
+ */
601
+ const validateModel = (selectedModel, supportedModels) => {
602
+ if (supportedModels.includes(selectedModel)) {
603
+ return selectedModel;
604
+ }
605
+ return supportedModels[0] ?? selectedModel;
606
+ };
592
607
  const MAX_VALIDATION_RETRIES = 3;
593
608
  const MAX_RATE_LIMIT_RETRIES = 2;
594
609
  const RATE_LIMIT_MIN_WAIT_MS = 30_000;
@@ -987,8 +1002,16 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
987
1002
  streamUsage = { input_tokens: 0, output_tokens: 0 };
988
1003
  try {
989
1004
  assertQuotaAvailable();
990
- const stream = await llm.stream(activeMessages);
1005
+ // Honour caller cancellation: passing the signal tears down the
1006
+ // upstream HTTP request so a cancelled call stops billing tokens.
1007
+ const abortSignal = (0, runtimeContext_js_1.getAbortSignal)();
1008
+ const stream = await llm.stream(activeMessages, {
1009
+ signal: abortSignal ?? undefined,
1010
+ });
991
1011
  for await (const chunk of stream) {
1012
+ if (abortSignal?.aborted) {
1013
+ throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "Request cancelled by caller");
1014
+ }
992
1015
  accumulateChunkUsage(streamUsage, chunk);
993
1016
  const content = chunk?.content || chunk;
994
1017
  if (typeof content === "string") {
@@ -1412,4 +1435,5 @@ exports.default = {
1412
1435
  runPromptWithModel,
1413
1436
  runPromptWithModelForImageGeneration,
1414
1437
  throwErrorIfNotSuccessful,
1438
+ validateModel,
1415
1439
  };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Per-request execution context for an agent invocation.
3
+ *
4
+ * Agents read `config`, `params`, `onProgress`, `request`, and `event` as
5
+ * globals (the historical VM-host contract). When agents run as a long-lived
6
+ * gRPC server (see {@link "./serve"}), a single process handles many concurrent
7
+ * requests, so a shared mutable global would let requests clobber each other.
8
+ * This module stores the context per async call-chain via {@link AsyncLocalStorage}
9
+ * and exposes the same names as global getters, keeping the agent programming
10
+ * model intact while being concurrency-safe.
11
+ */
12
+ export interface RequestContext {
13
+ config: any;
14
+ params: any;
15
+ request: any;
16
+ event: any;
17
+ onProgress: ((update: {
18
+ progress: number;
19
+ message: string;
20
+ }) => void) | null;
21
+ /** Aborted when the caller cancels (e.g. the frontend socket dropped). */
22
+ signal: AbortSignal | null;
23
+ /** Identity + tenant resolved from the request's auth token (see serve). */
24
+ session?: {
25
+ user: any;
26
+ tenant: any;
27
+ } | null;
28
+ }
29
+ /** Run `fn` with `ctx` bound as the active request context. */
30
+ export declare const runWithContext: <T>(ctx: RequestContext, fn: () => Promise<T>) => Promise<T>;
31
+ /** The active request context, or `undefined` outside a request. */
32
+ export declare const getContext: () => RequestContext | undefined;
33
+ /** The active request's abort signal, or `null`. */
34
+ export declare const getAbortSignal: () => AbortSignal | null;
35
+ /**
36
+ * Install global getters (`config`, `params`, `onProgress`, `request`, `event`)
37
+ * that resolve to the active {@link RequestContext}. Idempotent; call once at
38
+ * startup. After this, agent code referencing those globals transparently reads
39
+ * the current request's values even under concurrency.
40
+ */
41
+ export declare const installGlobals: () => void;
42
+ //# sourceMappingURL=runtimeContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtimeContext.d.ts","sourceRoot":"","sources":["../../src/runtimeContext.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,GAAG,CAAC;IACZ,MAAM,EAAE,GAAG,CAAC;IACZ,OAAO,EAAE,GAAG,CAAC;IACb,KAAK,EAAE,GAAG,CAAC;IACX,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAC7E,0EAA0E;IAC1E,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,4EAA4E;IAC5E,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,MAAM,EAAE,GAAG,CAAA;KAAE,GAAG,IAAI,CAAC;CAC7C;AAID,+DAA+D;AAC/D,eAAO,MAAM,cAAc,GAAI,CAAC,EAC9B,KAAK,cAAc,EACnB,IAAI,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAyB,CAAC;AAEtC,oEAAoE;AACpE,eAAO,MAAM,UAAU,QAAO,cAAc,GAAG,SAA+B,CAAC;AAE/E,oDAAoD;AACpD,eAAO,MAAM,cAAc,QAAO,WAAW,GAAG,IACZ,CAAC;AAIrC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,QAAO,IAUjC,CAAC"}
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installGlobals = exports.getAbortSignal = exports.getContext = exports.runWithContext = void 0;
4
+ const node_async_hooks_1 = require("node:async_hooks");
5
+ const storage = new node_async_hooks_1.AsyncLocalStorage();
6
+ /** Run `fn` with `ctx` bound as the active request context. */
7
+ const runWithContext = (ctx, fn) => storage.run(ctx, fn);
8
+ exports.runWithContext = runWithContext;
9
+ /** The active request context, or `undefined` outside a request. */
10
+ const getContext = () => storage.getStore();
11
+ exports.getContext = getContext;
12
+ /** The active request's abort signal, or `null`. */
13
+ const getAbortSignal = () => storage.getStore()?.signal ?? null;
14
+ exports.getAbortSignal = getAbortSignal;
15
+ let installed = false;
16
+ /**
17
+ * Install global getters (`config`, `params`, `onProgress`, `request`, `event`)
18
+ * that resolve to the active {@link RequestContext}. Idempotent; call once at
19
+ * startup. After this, agent code referencing those globals transparently reads
20
+ * the current request's values even under concurrency.
21
+ */
22
+ const installGlobals = () => {
23
+ if (installed)
24
+ return;
25
+ installed = true;
26
+ const define = (name, get) => Object.defineProperty(globalThis, name, { get, configurable: true });
27
+ define("config", () => storage.getStore()?.config);
28
+ define("params", () => storage.getStore()?.params);
29
+ define("request", () => storage.getStore()?.request);
30
+ define("event", () => storage.getStore()?.event);
31
+ define("onProgress", () => storage.getStore()?.onProgress);
32
+ };
33
+ exports.installGlobals = installGlobals;
@@ -0,0 +1,38 @@
1
+ import * as grpc from "@grpc/grpc-js";
2
+ /**
3
+ * The agent entry point. Reads `params` / `config` / `onProgress` from globals
4
+ * (populated per-request by {@link serve}) and returns the structured result.
5
+ * Receives the request's {@link AbortSignal} for cooperative cancellation; most
6
+ * agents can ignore it since the LLM layer honours it automatically.
7
+ */
8
+ export type AgentMain = (signal?: AbortSignal) => Promise<any>;
9
+ /**
10
+ * Resolves the caller's identity + tenant from the request, or throws to refuse
11
+ * the call. The default validates `request.authToken` against the StackFactor
12
+ * API via `client-api`'s `session.getSession`.
13
+ */
14
+ export type Authenticator = (request: any) => Promise<{
15
+ user: any;
16
+ tenant: any;
17
+ }>;
18
+ export interface ServeOptions {
19
+ /** Port to bind. Defaults to `process.env.PORT` then `8080` (Cloud Run). */
20
+ port?: number;
21
+ /** Host to bind. Defaults to `0.0.0.0`. */
22
+ host?: string;
23
+ /** Override the auth check (e.g. in tests). Defaults to {@link defaultAuthenticate}. */
24
+ authenticate?: Authenticator;
25
+ }
26
+ /**
27
+ * Start a gRPC server that exposes `main` as the fleet-standard `Agent.Execute`
28
+ * RPC. Per request it binds a {@link RequestContext} (so the agent's globals are
29
+ * concurrency-safe), streams each `onProgress` event as a `Progress` frame, and
30
+ * sends a final `Result` or `Error`. Caller cancellation (e.g. the frontend
31
+ * socket dropping) aborts the in-flight LLM call to stop billing.
32
+ *
33
+ * @example
34
+ * const { serve } = require("@stackfactor/agent-utils");
35
+ * serve(require("./src/main"));
36
+ */
37
+ export declare const serve: (main: AgentMain, options?: ServeOptions) => grpc.Server;
38
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AAStC;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,CAAC;AAElF,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B;AAqGD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAAI,MAAM,SAAS,EAAE,UAAS,YAAiB,KAAG,IAAI,CAAC,MAsGxE,CAAC"}
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.serve = void 0;
40
+ const grpc = __importStar(require("@grpc/grpc-js"));
41
+ const protoLoader = __importStar(require("@grpc/proto-loader"));
42
+ const client_api_1 = require("@stackfactor/client-api");
43
+ const node_fs_1 = require("node:fs");
44
+ const node_path_1 = require("node:path");
45
+ const node_os_1 = require("node:os");
46
+ const logger_js_1 = __importDefault(require("./logger.js"));
47
+ const runtimeContext_js_1 = require("./runtimeContext.js");
48
+ /** Marks an auth failure so the handler can map it to gRPC UNAUTHENTICATED. */
49
+ class UnauthenticatedError extends Error {
50
+ }
51
+ /**
52
+ * Default authenticator: require a token on the request and resolve it to a
53
+ * session via the StackFactor API. Requires `BACKEND_URL` (or `REACT_APP_NODE_ENV`)
54
+ * to point client-api at the right backend. Throws {@link UnauthenticatedError}
55
+ * when the token is missing or rejected.
56
+ */
57
+ const defaultAuthenticate = async (request) => {
58
+ const token = request?.authToken ?? request?.authorization;
59
+ if (!token) {
60
+ throw new UnauthenticatedError("Missing auth token");
61
+ }
62
+ try {
63
+ return await client_api_1.session.getSession(token);
64
+ }
65
+ catch (error) {
66
+ throw new UnauthenticatedError(`Invalid auth token: ${error?.message ?? error}`);
67
+ }
68
+ };
69
+ /**
70
+ * gRPC contract shared by the entire agent fleet. `data`/`config`/`request` are
71
+ * carried as JSON strings so arbitrary payloads need no proto schema churn.
72
+ * `Execute` is server-streaming: zero or more `Progress` frames (driven by the
73
+ * agent's `onProgress`) followed by exactly one `Result` or `Error`.
74
+ */
75
+ const PROTO = `
76
+ syntax = "proto3";
77
+ package stackfactor.agent.v1;
78
+
79
+ service Agent {
80
+ rpc Execute(ExecuteRequest) returns (stream Update);
81
+ }
82
+
83
+ message ExecuteRequest {
84
+ string content_type = 1;
85
+ string data_json = 2;
86
+ string config_json = 3;
87
+ string request_json = 4;
88
+ int32 event = 5;
89
+ }
90
+
91
+ message Update {
92
+ oneof payload {
93
+ Progress progress = 1;
94
+ Result result = 2;
95
+ ErrorInfo error = 3;
96
+ }
97
+ }
98
+
99
+ message Progress { int32 progress = 1; string message = 2; }
100
+ message Result { string result_json = 1; }
101
+ message ErrorInfo { int32 code = 1; string message = 2; }
102
+ `;
103
+ const loadAgentService = () => {
104
+ // proto-loader reads from a file; write the embedded schema to a temp path so
105
+ // the package stays self-contained across the cjs/esm dual build.
106
+ const file = (0, node_path_1.join)((0, node_os_1.tmpdir)(), "stackfactor-agent.v1.proto");
107
+ (0, node_fs_1.writeFileSync)(file, PROTO);
108
+ const packageDefinition = protoLoader.loadSync(file, {
109
+ keepCase: true,
110
+ longs: String,
111
+ enums: String,
112
+ defaults: true,
113
+ oneofs: true,
114
+ });
115
+ const loaded = grpc.loadPackageDefinition(packageDefinition);
116
+ return loaded.stackfactor.agent.v1.Agent.service;
117
+ };
118
+ const safeParse = (value, fallback) => {
119
+ if (!value)
120
+ return fallback;
121
+ try {
122
+ return JSON.parse(value);
123
+ }
124
+ catch {
125
+ return fallback;
126
+ }
127
+ };
128
+ /**
129
+ * Agents that fail throw `new Error(JSON.stringify({ code, message }))`. Unwrap
130
+ * that back into a structured gRPC error frame; fall back to a 500 otherwise.
131
+ */
132
+ const toErrorFrame = (error) => {
133
+ try {
134
+ const parsed = JSON.parse(error?.message);
135
+ if (parsed && typeof parsed.code === "number") {
136
+ return { code: parsed.code, message: String(parsed.message ?? "") };
137
+ }
138
+ }
139
+ catch {
140
+ // not a structured error
141
+ }
142
+ return { code: 500, message: String(error?.message ?? error) };
143
+ };
144
+ /**
145
+ * Start a gRPC server that exposes `main` as the fleet-standard `Agent.Execute`
146
+ * RPC. Per request it binds a {@link RequestContext} (so the agent's globals are
147
+ * concurrency-safe), streams each `onProgress` event as a `Progress` frame, and
148
+ * sends a final `Result` or `Error`. Caller cancellation (e.g. the frontend
149
+ * socket dropping) aborts the in-flight LLM call to stop billing.
150
+ *
151
+ * @example
152
+ * const { serve } = require("@stackfactor/agent-utils");
153
+ * serve(require("./src/main"));
154
+ */
155
+ const serve = (main, options = {}) => {
156
+ (0, runtimeContext_js_1.installGlobals)();
157
+ const port = options.port ?? (Number(process.env.PORT) || 8080);
158
+ const host = options.host ?? "0.0.0.0";
159
+ const authenticate = options.authenticate ?? defaultAuthenticate;
160
+ const execute = (call) => {
161
+ const abort = new AbortController();
162
+ call.on("cancelled", () => abort.abort());
163
+ call.on("error", () => abort.abort());
164
+ const req = call.request ?? {};
165
+ const request = safeParse(req.request_json, {});
166
+ const onProgress = (update) => {
167
+ if (abort.signal.aborted)
168
+ return;
169
+ call.write({
170
+ progress: {
171
+ progress: Math.round(update?.progress ?? 0),
172
+ message: update?.message ?? "",
173
+ },
174
+ });
175
+ };
176
+ const handle = async () => {
177
+ // Authenticate first — refuse the call when the token is missing/invalid.
178
+ let session;
179
+ try {
180
+ session = await authenticate(request);
181
+ }
182
+ catch (error) {
183
+ const message = error?.message ?? "Unauthenticated";
184
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `agent Execute refused: ${message}`);
185
+ call.emit("error", {
186
+ code: grpc.status.UNAUTHENTICATED,
187
+ details: message,
188
+ });
189
+ return;
190
+ }
191
+ const context = {
192
+ config: safeParse(req.config_json, {}),
193
+ params: {
194
+ contentType: req.content_type,
195
+ data: safeParse(req.data_json, {}),
196
+ },
197
+ request: { ...request, session }, // expose identity/tenant to the agent
198
+ event: req.event ?? 0,
199
+ onProgress,
200
+ signal: abort.signal,
201
+ session,
202
+ };
203
+ try {
204
+ const result = await (0, runtimeContext_js_1.runWithContext)(context, () => main(abort.signal));
205
+ if (!abort.signal.aborted) {
206
+ call.write({ result: { result_json: JSON.stringify(result ?? null) } });
207
+ }
208
+ }
209
+ catch (error) {
210
+ if (!abort.signal.aborted) {
211
+ logger_js_1.default.log(null, logger_js_1.default.levels.error, `agent Execute failed: ${error?.message ?? error}`);
212
+ call.write({ error: toErrorFrame(error) });
213
+ }
214
+ }
215
+ finally {
216
+ call.end();
217
+ }
218
+ };
219
+ void handle();
220
+ };
221
+ const server = new grpc.Server();
222
+ server.addService(loadAgentService(), { Execute: execute });
223
+ // Insecure: Cloud Run terminates TLS at the edge and forwards h2c to the
224
+ // container (requires the service to be deployed with --use-http2).
225
+ server.bindAsync(`${host}:${port}`, grpc.ServerCredentials.createInsecure(), (err, boundPort) => {
226
+ if (err) {
227
+ logger_js_1.default.log(null, logger_js_1.default.levels.error, `agent gRPC server failed to bind ${host}:${port}: ${err.message}`);
228
+ throw err;
229
+ }
230
+ logger_js_1.default.log(null, logger_js_1.default.levels.info, `agent gRPC server listening on ${host}:${boundPort}`);
231
+ });
232
+ return server;
233
+ };
234
+ exports.serve = serve;
@@ -2,8 +2,12 @@ import constants from "./const.js";
2
2
  import errorHandling from "./errorHandling.js";
3
3
  import langChain from "./langChain.js";
4
4
  import logger from "./logger.js";
5
+ import { serve } from "./serve.js";
6
+ import * as runtimeContext from "./runtimeContext.js";
5
7
  export { constants };
6
8
  export { errorHandling };
7
9
  export { langChain };
8
10
  export { logger };
11
+ export { serve };
12
+ export { runtimeContext };
9
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAC/C,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,CAAC;AAEzB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,MAAM,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAC/C,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,KAAK,cAAc,MAAM,qBAAqB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,CAAC;AAEzB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,OAAO,EAAE,KAAK,EAAE,CAAC;AAEjB,OAAO,EAAE,cAAc,EAAE,CAAC"}
package/dist/esm/index.js CHANGED
@@ -2,7 +2,11 @@ import constants from "./const.js";
2
2
  import errorHandling from "./errorHandling.js";
3
3
  import langChain from "./langChain.js";
4
4
  import logger from "./logger.js";
5
+ import { serve } from "./serve.js";
6
+ import * as runtimeContext from "./runtimeContext.js";
5
7
  export { constants };
6
8
  export { errorHandling };
7
9
  export { langChain };
8
10
  export { logger };
11
+ export { serve };
12
+ export { runtimeContext };
@@ -5,6 +5,7 @@ declare const _default: {
5
5
  runPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any, minPercent?: number, maxPercent?: number, expectsJsonResponse?: boolean, schema?: any, agentName?: string, tools?: any[]) => Promise<any>;
6
6
  runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any) => Promise<any>;
7
7
  throwErrorIfNotSuccessful: (response: any) => string;
8
+ validateModel: (selectedModel: string, supportedModels: string[]) => string;
8
9
  };
9
10
  export default _default;
10
11
  //# sourceMappingURL=langChain.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";0CAgD6C,GAAG,KAAG,IAAI;wBAke/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;oCAqWF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDA6rBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA18B8B,GAAG,KAAG,MAAM;;AAqgCzD,wBAOE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";0CAiD6C,GAAG,KAAG,IAAI;wBAke/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;oCAuXF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDAwsBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CAv+B8B,GAAG,KAAG,MAAM;mCAoBxC,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AA4gCT,wBAQE"}
@@ -8,6 +8,7 @@ import { createAgent as createLangChainAgent } from "langchain";
8
8
  import errorHandlingHelper from "./errorHandling.js";
9
9
  import logger from "./logger.js";
10
10
  import { zodToJsonSchema } from "zod-to-json-schema";
11
+ import { getAbortSignal } from "./runtimeContext.js";
11
12
  const JSON_ESCAPE_INSTRUCTION = `
12
13
  CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
13
14
  - Newlines → \\n
@@ -584,6 +585,20 @@ const throwErrorIfNotSuccessful = (response) => {
584
585
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, constants.ERROR.UNABLE_TO_GENERATE_CONTENT);
585
586
  }
586
587
  };
588
+ /**
589
+ * Returns `selectedModel` when it appears in `supportedModels`, otherwise returns
590
+ * the first entry in `supportedModels` as a safe fallback. Use this to coerce a
591
+ * caller-supplied model identifier to one the current integration is known to
592
+ * support, instead of failing or silently passing an unsupported model to
593
+ * `getLLMModel`. When `supportedModels` is empty there is no fallback to pick,
594
+ * so the original `selectedModel` is returned unchanged.
595
+ */
596
+ const validateModel = (selectedModel, supportedModels) => {
597
+ if (supportedModels.includes(selectedModel)) {
598
+ return selectedModel;
599
+ }
600
+ return supportedModels[0] ?? selectedModel;
601
+ };
587
602
  const MAX_VALIDATION_RETRIES = 3;
588
603
  const MAX_RATE_LIMIT_RETRIES = 2;
589
604
  const RATE_LIMIT_MIN_WAIT_MS = 30_000;
@@ -982,8 +997,16 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
982
997
  streamUsage = { input_tokens: 0, output_tokens: 0 };
983
998
  try {
984
999
  assertQuotaAvailable();
985
- const stream = await llm.stream(activeMessages);
1000
+ // Honour caller cancellation: passing the signal tears down the
1001
+ // upstream HTTP request so a cancelled call stops billing tokens.
1002
+ const abortSignal = getAbortSignal();
1003
+ const stream = await llm.stream(activeMessages, {
1004
+ signal: abortSignal ?? undefined,
1005
+ });
986
1006
  for await (const chunk of stream) {
1007
+ if (abortSignal?.aborted) {
1008
+ throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, "Request cancelled by caller");
1009
+ }
987
1010
  accumulateChunkUsage(streamUsage, chunk);
988
1011
  const content = chunk?.content || chunk;
989
1012
  if (typeof content === "string") {
@@ -1407,4 +1430,5 @@ export default {
1407
1430
  runPromptWithModel,
1408
1431
  runPromptWithModelForImageGeneration,
1409
1432
  throwErrorIfNotSuccessful,
1433
+ validateModel,
1410
1434
  };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Per-request execution context for an agent invocation.
3
+ *
4
+ * Agents read `config`, `params`, `onProgress`, `request`, and `event` as
5
+ * globals (the historical VM-host contract). When agents run as a long-lived
6
+ * gRPC server (see {@link "./serve"}), a single process handles many concurrent
7
+ * requests, so a shared mutable global would let requests clobber each other.
8
+ * This module stores the context per async call-chain via {@link AsyncLocalStorage}
9
+ * and exposes the same names as global getters, keeping the agent programming
10
+ * model intact while being concurrency-safe.
11
+ */
12
+ export interface RequestContext {
13
+ config: any;
14
+ params: any;
15
+ request: any;
16
+ event: any;
17
+ onProgress: ((update: {
18
+ progress: number;
19
+ message: string;
20
+ }) => void) | null;
21
+ /** Aborted when the caller cancels (e.g. the frontend socket dropped). */
22
+ signal: AbortSignal | null;
23
+ /** Identity + tenant resolved from the request's auth token (see serve). */
24
+ session?: {
25
+ user: any;
26
+ tenant: any;
27
+ } | null;
28
+ }
29
+ /** Run `fn` with `ctx` bound as the active request context. */
30
+ export declare const runWithContext: <T>(ctx: RequestContext, fn: () => Promise<T>) => Promise<T>;
31
+ /** The active request context, or `undefined` outside a request. */
32
+ export declare const getContext: () => RequestContext | undefined;
33
+ /** The active request's abort signal, or `null`. */
34
+ export declare const getAbortSignal: () => AbortSignal | null;
35
+ /**
36
+ * Install global getters (`config`, `params`, `onProgress`, `request`, `event`)
37
+ * that resolve to the active {@link RequestContext}. Idempotent; call once at
38
+ * startup. After this, agent code referencing those globals transparently reads
39
+ * the current request's values even under concurrency.
40
+ */
41
+ export declare const installGlobals: () => void;
42
+ //# sourceMappingURL=runtimeContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtimeContext.d.ts","sourceRoot":"","sources":["../../src/runtimeContext.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,GAAG,CAAC;IACZ,MAAM,EAAE,GAAG,CAAC;IACZ,OAAO,EAAE,GAAG,CAAC;IACb,KAAK,EAAE,GAAG,CAAC;IACX,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAC7E,0EAA0E;IAC1E,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,4EAA4E;IAC5E,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,MAAM,EAAE,GAAG,CAAA;KAAE,GAAG,IAAI,CAAC;CAC7C;AAID,+DAA+D;AAC/D,eAAO,MAAM,cAAc,GAAI,CAAC,EAC9B,KAAK,cAAc,EACnB,IAAI,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAyB,CAAC;AAEtC,oEAAoE;AACpE,eAAO,MAAM,UAAU,QAAO,cAAc,GAAG,SAA+B,CAAC;AAE/E,oDAAoD;AACpD,eAAO,MAAM,cAAc,QAAO,WAAW,GAAG,IACZ,CAAC;AAIrC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,QAAO,IAUjC,CAAC"}
@@ -0,0 +1,26 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const storage = new AsyncLocalStorage();
3
+ /** Run `fn` with `ctx` bound as the active request context. */
4
+ export const runWithContext = (ctx, fn) => storage.run(ctx, fn);
5
+ /** The active request context, or `undefined` outside a request. */
6
+ export const getContext = () => storage.getStore();
7
+ /** The active request's abort signal, or `null`. */
8
+ export const getAbortSignal = () => storage.getStore()?.signal ?? null;
9
+ let installed = false;
10
+ /**
11
+ * Install global getters (`config`, `params`, `onProgress`, `request`, `event`)
12
+ * that resolve to the active {@link RequestContext}. Idempotent; call once at
13
+ * startup. After this, agent code referencing those globals transparently reads
14
+ * the current request's values even under concurrency.
15
+ */
16
+ export const installGlobals = () => {
17
+ if (installed)
18
+ return;
19
+ installed = true;
20
+ const define = (name, get) => Object.defineProperty(globalThis, name, { get, configurable: true });
21
+ define("config", () => storage.getStore()?.config);
22
+ define("params", () => storage.getStore()?.params);
23
+ define("request", () => storage.getStore()?.request);
24
+ define("event", () => storage.getStore()?.event);
25
+ define("onProgress", () => storage.getStore()?.onProgress);
26
+ };
@@ -0,0 +1,38 @@
1
+ import * as grpc from "@grpc/grpc-js";
2
+ /**
3
+ * The agent entry point. Reads `params` / `config` / `onProgress` from globals
4
+ * (populated per-request by {@link serve}) and returns the structured result.
5
+ * Receives the request's {@link AbortSignal} for cooperative cancellation; most
6
+ * agents can ignore it since the LLM layer honours it automatically.
7
+ */
8
+ export type AgentMain = (signal?: AbortSignal) => Promise<any>;
9
+ /**
10
+ * Resolves the caller's identity + tenant from the request, or throws to refuse
11
+ * the call. The default validates `request.authToken` against the StackFactor
12
+ * API via `client-api`'s `session.getSession`.
13
+ */
14
+ export type Authenticator = (request: any) => Promise<{
15
+ user: any;
16
+ tenant: any;
17
+ }>;
18
+ export interface ServeOptions {
19
+ /** Port to bind. Defaults to `process.env.PORT` then `8080` (Cloud Run). */
20
+ port?: number;
21
+ /** Host to bind. Defaults to `0.0.0.0`. */
22
+ host?: string;
23
+ /** Override the auth check (e.g. in tests). Defaults to {@link defaultAuthenticate}. */
24
+ authenticate?: Authenticator;
25
+ }
26
+ /**
27
+ * Start a gRPC server that exposes `main` as the fleet-standard `Agent.Execute`
28
+ * RPC. Per request it binds a {@link RequestContext} (so the agent's globals are
29
+ * concurrency-safe), streams each `onProgress` event as a `Progress` frame, and
30
+ * sends a final `Result` or `Error`. Caller cancellation (e.g. the frontend
31
+ * socket dropping) aborts the in-flight LLM call to stop billing.
32
+ *
33
+ * @example
34
+ * const { serve } = require("@stackfactor/agent-utils");
35
+ * serve(require("./src/main"));
36
+ */
37
+ export declare const serve: (main: AgentMain, options?: ServeOptions) => grpc.Server;
38
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AAStC;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,CAAC;AAElF,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B;AAqGD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAAI,MAAM,SAAS,EAAE,UAAS,YAAiB,KAAG,IAAI,CAAC,MAsGxE,CAAC"}
@@ -0,0 +1,194 @@
1
+ import * as grpc from "@grpc/grpc-js";
2
+ import * as protoLoader from "@grpc/proto-loader";
3
+ import { session as clientSession } from "@stackfactor/client-api";
4
+ import { writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import logger from "./logger.js";
8
+ import { runWithContext, installGlobals } from "./runtimeContext.js";
9
+ /** Marks an auth failure so the handler can map it to gRPC UNAUTHENTICATED. */
10
+ class UnauthenticatedError extends Error {
11
+ }
12
+ /**
13
+ * Default authenticator: require a token on the request and resolve it to a
14
+ * session via the StackFactor API. Requires `BACKEND_URL` (or `REACT_APP_NODE_ENV`)
15
+ * to point client-api at the right backend. Throws {@link UnauthenticatedError}
16
+ * when the token is missing or rejected.
17
+ */
18
+ const defaultAuthenticate = async (request) => {
19
+ const token = request?.authToken ?? request?.authorization;
20
+ if (!token) {
21
+ throw new UnauthenticatedError("Missing auth token");
22
+ }
23
+ try {
24
+ return await clientSession.getSession(token);
25
+ }
26
+ catch (error) {
27
+ throw new UnauthenticatedError(`Invalid auth token: ${error?.message ?? error}`);
28
+ }
29
+ };
30
+ /**
31
+ * gRPC contract shared by the entire agent fleet. `data`/`config`/`request` are
32
+ * carried as JSON strings so arbitrary payloads need no proto schema churn.
33
+ * `Execute` is server-streaming: zero or more `Progress` frames (driven by the
34
+ * agent's `onProgress`) followed by exactly one `Result` or `Error`.
35
+ */
36
+ const PROTO = `
37
+ syntax = "proto3";
38
+ package stackfactor.agent.v1;
39
+
40
+ service Agent {
41
+ rpc Execute(ExecuteRequest) returns (stream Update);
42
+ }
43
+
44
+ message ExecuteRequest {
45
+ string content_type = 1;
46
+ string data_json = 2;
47
+ string config_json = 3;
48
+ string request_json = 4;
49
+ int32 event = 5;
50
+ }
51
+
52
+ message Update {
53
+ oneof payload {
54
+ Progress progress = 1;
55
+ Result result = 2;
56
+ ErrorInfo error = 3;
57
+ }
58
+ }
59
+
60
+ message Progress { int32 progress = 1; string message = 2; }
61
+ message Result { string result_json = 1; }
62
+ message ErrorInfo { int32 code = 1; string message = 2; }
63
+ `;
64
+ const loadAgentService = () => {
65
+ // proto-loader reads from a file; write the embedded schema to a temp path so
66
+ // the package stays self-contained across the cjs/esm dual build.
67
+ const file = join(tmpdir(), "stackfactor-agent.v1.proto");
68
+ writeFileSync(file, PROTO);
69
+ const packageDefinition = protoLoader.loadSync(file, {
70
+ keepCase: true,
71
+ longs: String,
72
+ enums: String,
73
+ defaults: true,
74
+ oneofs: true,
75
+ });
76
+ const loaded = grpc.loadPackageDefinition(packageDefinition);
77
+ return loaded.stackfactor.agent.v1.Agent.service;
78
+ };
79
+ const safeParse = (value, fallback) => {
80
+ if (!value)
81
+ return fallback;
82
+ try {
83
+ return JSON.parse(value);
84
+ }
85
+ catch {
86
+ return fallback;
87
+ }
88
+ };
89
+ /**
90
+ * Agents that fail throw `new Error(JSON.stringify({ code, message }))`. Unwrap
91
+ * that back into a structured gRPC error frame; fall back to a 500 otherwise.
92
+ */
93
+ const toErrorFrame = (error) => {
94
+ try {
95
+ const parsed = JSON.parse(error?.message);
96
+ if (parsed && typeof parsed.code === "number") {
97
+ return { code: parsed.code, message: String(parsed.message ?? "") };
98
+ }
99
+ }
100
+ catch {
101
+ // not a structured error
102
+ }
103
+ return { code: 500, message: String(error?.message ?? error) };
104
+ };
105
+ /**
106
+ * Start a gRPC server that exposes `main` as the fleet-standard `Agent.Execute`
107
+ * RPC. Per request it binds a {@link RequestContext} (so the agent's globals are
108
+ * concurrency-safe), streams each `onProgress` event as a `Progress` frame, and
109
+ * sends a final `Result` or `Error`. Caller cancellation (e.g. the frontend
110
+ * socket dropping) aborts the in-flight LLM call to stop billing.
111
+ *
112
+ * @example
113
+ * const { serve } = require("@stackfactor/agent-utils");
114
+ * serve(require("./src/main"));
115
+ */
116
+ export const serve = (main, options = {}) => {
117
+ installGlobals();
118
+ const port = options.port ?? (Number(process.env.PORT) || 8080);
119
+ const host = options.host ?? "0.0.0.0";
120
+ const authenticate = options.authenticate ?? defaultAuthenticate;
121
+ const execute = (call) => {
122
+ const abort = new AbortController();
123
+ call.on("cancelled", () => abort.abort());
124
+ call.on("error", () => abort.abort());
125
+ const req = call.request ?? {};
126
+ const request = safeParse(req.request_json, {});
127
+ const onProgress = (update) => {
128
+ if (abort.signal.aborted)
129
+ return;
130
+ call.write({
131
+ progress: {
132
+ progress: Math.round(update?.progress ?? 0),
133
+ message: update?.message ?? "",
134
+ },
135
+ });
136
+ };
137
+ const handle = async () => {
138
+ // Authenticate first — refuse the call when the token is missing/invalid.
139
+ let session;
140
+ try {
141
+ session = await authenticate(request);
142
+ }
143
+ catch (error) {
144
+ const message = error?.message ?? "Unauthenticated";
145
+ logger.log(null, logger.levels.warn, `agent Execute refused: ${message}`);
146
+ call.emit("error", {
147
+ code: grpc.status.UNAUTHENTICATED,
148
+ details: message,
149
+ });
150
+ return;
151
+ }
152
+ const context = {
153
+ config: safeParse(req.config_json, {}),
154
+ params: {
155
+ contentType: req.content_type,
156
+ data: safeParse(req.data_json, {}),
157
+ },
158
+ request: { ...request, session }, // expose identity/tenant to the agent
159
+ event: req.event ?? 0,
160
+ onProgress,
161
+ signal: abort.signal,
162
+ session,
163
+ };
164
+ try {
165
+ const result = await runWithContext(context, () => main(abort.signal));
166
+ if (!abort.signal.aborted) {
167
+ call.write({ result: { result_json: JSON.stringify(result ?? null) } });
168
+ }
169
+ }
170
+ catch (error) {
171
+ if (!abort.signal.aborted) {
172
+ logger.log(null, logger.levels.error, `agent Execute failed: ${error?.message ?? error}`);
173
+ call.write({ error: toErrorFrame(error) });
174
+ }
175
+ }
176
+ finally {
177
+ call.end();
178
+ }
179
+ };
180
+ void handle();
181
+ };
182
+ const server = new grpc.Server();
183
+ server.addService(loadAgentService(), { Execute: execute });
184
+ // Insecure: Cloud Run terminates TLS at the edge and forwards h2c to the
185
+ // container (requires the service to be deployed with --use-http2).
186
+ server.bindAsync(`${host}:${port}`, grpc.ServerCredentials.createInsecure(), (err, boundPort) => {
187
+ if (err) {
188
+ logger.log(null, logger.levels.error, `agent gRPC server failed to bind ${host}:${port}: ${err.message}`);
189
+ throw err;
190
+ }
191
+ logger.log(null, logger.levels.info, `agent gRPC server listening on ${host}:${boundPort}`);
192
+ });
193
+ return server;
194
+ };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "restricted"
5
5
  },
6
- "version": "1.0.23",
6
+ "version": "1.1.0",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",
@@ -40,10 +40,13 @@
40
40
  "homepage": "https://github.com/StackFactor/stackfactor-agent-utils#readme",
41
41
  "dependencies": {
42
42
  "@google/genai": "^1.46.0",
43
+ "@grpc/grpc-js": "^1.14.4",
44
+ "@grpc/proto-loader": "^0.7.15",
43
45
  "@langchain/anthropic": "^1.3.25",
44
46
  "@langchain/google-genai": "^2.1.26",
45
47
  "@langchain/openai": "^1.3.0",
46
48
  "@opentelemetry/api": "^1.9.0",
49
+ "@stackfactor/client-api": "^1.1.239",
47
50
  "langchain": "^1.2.35",
48
51
  "mongoose": "^9.3.1",
49
52
  "openai": "^6.32.0",