@pentatonic-ai/ai-agent-sdk 0.7.11 → 0.7.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.7.11",
3
+ "version": "0.7.12",
4
4
  "description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -231,6 +231,63 @@ describe("engine HTTP client", () => {
231
231
  });
232
232
  });
233
233
 
234
+ describe("opts.headers passthrough (CF Access)", () => {
235
+ const cfAccess = {
236
+ "CF-Access-Client-Id": "tes-worker.id",
237
+ "CF-Access-Client-Secret": "shh-it-secret",
238
+ };
239
+
240
+ it("fetchEngine merges opts.headers on top of the default content-type", async () => {
241
+ mockOk({});
242
+ await fetchEngine("https://e", "/store", { a: 1 }, { headers: cfAccess });
243
+ const sent = calls[0].init.headers;
244
+ expect(sent["content-type"]).toBe("application/json");
245
+ expect(sent["CF-Access-Client-Id"]).toBe("tes-worker.id");
246
+ expect(sent["CF-Access-Client-Secret"]).toBe("shh-it-secret");
247
+ });
248
+
249
+ it("engineStore forwards opts.headers", async () => {
250
+ mockOk({ id: "x", content: "x", layerId: "ml_acme_episodic" });
251
+ await engineStore("https://e", {
252
+ clientId: "acme",
253
+ content: "x",
254
+ headers: cfAccess,
255
+ });
256
+ const sent = calls[0].init.headers;
257
+ expect(sent["CF-Access-Client-Id"]).toBe("tes-worker.id");
258
+ });
259
+
260
+ it("engineSearch forwards opts.headers", async () => {
261
+ mockOk({ results: [] });
262
+ await engineSearch("https://e", {
263
+ clientId: "acme",
264
+ query: "x",
265
+ headers: cfAccess,
266
+ });
267
+ const sent = calls[0].init.headers;
268
+ expect(sent["CF-Access-Client-Id"]).toBe("tes-worker.id");
269
+ expect(sent["CF-Access-Client-Secret"]).toBe("shh-it-secret");
270
+ });
271
+
272
+ it("engineForget forwards opts.headers", async () => {
273
+ mockOk({ deleted: 0 });
274
+ await engineForget("https://e", {
275
+ clientId: "acme",
276
+ id: "abc",
277
+ headers: cfAccess,
278
+ });
279
+ const sent = calls[0].init.headers;
280
+ expect(sent["CF-Access-Client-Id"]).toBe("tes-worker.id");
281
+ });
282
+
283
+ it("no headers sent when opts.headers omitted (back-compat)", async () => {
284
+ mockOk({});
285
+ await engineStore("https://e", { clientId: "acme", content: "x" });
286
+ const sent = calls[0].init.headers;
287
+ expect(Object.keys(sent)).toEqual(["content-type"]);
288
+ });
289
+ });
290
+
234
291
  describe("composeArena", () => {
235
292
  it("tenant scope by default when no userId", () => {
236
293
  expect(composeArena("acme")).toBe("acme");
@@ -308,6 +308,11 @@ export function hostedAdapter(config, opts = {}) {
308
308
  * @param {string} config.engineUrl - e.g. "http://localhost:8099"
309
309
  * @param {string} [config.arena] - tenant scope; defaults to "default"
310
310
  * @param {string} [config.apiKey] - optional Authorization: Bearer
311
+ * @param {string} [config.cfAccessClientId] - CF Access service token id;
312
+ * sent as `CF-Access-Client-Id` when the engine is locked behind a
313
+ * Cloudflare Access policy.
314
+ * @param {string} [config.cfAccessClientSecret] - paired secret; sent as
315
+ * `CF-Access-Client-Secret`.
311
316
  * @param {object} [opts]
312
317
  * @param {number} [opts.timeoutMs=30000]
313
318
  * @returns {{ingestChunk, deleteByCorpusFile, init}}
@@ -319,11 +324,15 @@ export function engineAdapter(config, opts = {}) {
319
324
  }
320
325
  const arena = config.arena || "default";
321
326
  const apiKey = config.apiKey || null;
327
+ const cfAccessId = config.cfAccessClientId || null;
328
+ const cfAccessSecret = config.cfAccessClientSecret || null;
322
329
  const timeoutMs = opts.timeoutMs ?? 30000;
323
330
 
324
331
  function headers() {
325
332
  const h = { "content-type": "application/json" };
326
333
  if (apiKey) h["authorization"] = `Bearer ${apiKey}`;
334
+ if (cfAccessId) h["CF-Access-Client-Id"] = cfAccessId;
335
+ if (cfAccessSecret) h["CF-Access-Client-Secret"] = cfAccessSecret;
327
336
  return h;
328
337
  }
329
338
 
@@ -99,6 +99,13 @@ function readPluginConfig() {
99
99
  }
100
100
 
101
101
  function buildAdapterOrFail() {
102
+ // CF Access service token (optional). When the engine domain is
103
+ // locked behind a CF Access policy, callers need these headers or
104
+ // the edge returns 403 before traffic reaches the tunnel. Env-var
105
+ // names match the canonical Cloudflare Access convention.
106
+ const cfAccessClientId = process.env.CF_ACCESS_CLIENT_ID || null;
107
+ const cfAccessClientSecret = process.env.CF_ACCESS_CLIENT_SECRET || null;
108
+
102
109
  // 1. Env-var override (CI / scripts / explicit). Highest precedence.
103
110
  const envEngineUrl =
104
111
  process.env.MEMORY_ENGINE_URL || process.env.PENTATONIC_ENGINE_URL || null;
@@ -114,6 +121,8 @@ function buildAdapterOrFail() {
114
121
  engineUrl: envEngineUrl,
115
122
  arena,
116
123
  apiKey: process.env.MEMORY_ENGINE_API_KEY || null,
124
+ cfAccessClientId,
125
+ cfAccessClientSecret,
117
126
  }),
118
127
  };
119
128
  }
@@ -124,7 +133,12 @@ function buildAdapterOrFail() {
124
133
  const arena = pluginConfig.client_id || "default";
125
134
  return {
126
135
  tenant: { source: `plugin-config (${pluginConfig._path})`, engineUrl: pluginConfig.memory_url, arena },
127
- adapter: engineAdapter({ engineUrl: pluginConfig.memory_url, arena }),
136
+ adapter: engineAdapter({
137
+ engineUrl: pluginConfig.memory_url,
138
+ arena,
139
+ cfAccessClientId,
140
+ cfAccessClientSecret,
141
+ }),
128
142
  };
129
143
  }
130
144
 
@@ -67,19 +67,31 @@ export const DEFAULT_MIN_SCORE = 0.3;
67
67
  * @param {string} engineUrl - engine base URL (no trailing slash)
68
68
  * @param {string} path - "/store" | "/search" | "/forget" | "/health" | "/store-batch"
69
69
  * @param {object} body - JSON body, serialised verbatim
70
+ * @param {object} [opts]
71
+ * @param {Record<string,string>} [opts.headers] - additional request
72
+ * headers; merged on top of the default `content-type`. Canonical
73
+ * use case is Cloudflare Access service tokens
74
+ * (`CF-Access-Client-Id` + `CF-Access-Client-Secret`) when the
75
+ * engine domain is locked behind a CF Access policy. Any auth
76
+ * scheme can flow through this — the helper makes no assumptions
77
+ * about header names.
70
78
  * @returns {Promise<object>} parsed JSON response
71
79
  * @throws {Error} - "engine_<status>" with `.detail` set to response text
72
80
  * on HTTP non-2xx, or "engine_network: <msg>" on
73
81
  * transport failure.
74
82
  */
75
- export async function fetchEngine(engineUrl, path, body) {
83
+ export async function fetchEngine(engineUrl, path, body, opts = {}) {
76
84
  const base = engineUrl || DEFAULT_ENGINE_URL;
77
85
  const url = `${base}${path}`;
86
+ // Default content-type first so callers can override it via opts.headers
87
+ // if they ever need to (none do today, but the spread makes the rule
88
+ // explicit: caller wins on conflict).
89
+ const headers = { "content-type": "application/json", ...(opts.headers || {}) };
78
90
  let res;
79
91
  try {
80
92
  res = await fetch(url, {
81
93
  method: "POST",
82
- headers: { "content-type": "application/json" },
94
+ headers,
83
95
  body: JSON.stringify(body),
84
96
  });
85
97
  } catch (err) {
@@ -163,6 +175,9 @@ export function composeArenas(clientId, userId) {
163
175
  * @param {object} [opts.metadata] extra metadata; merged into engine body
164
176
  * @param {string} [opts.layerType] "episodic" | "semantic" | "procedural" | "working"
165
177
  * @param {string} [opts.actorUserId] passes through as metadata.actor_user_id
178
+ * @param {Record<string,string>} [opts.headers] forwarded HTTP headers
179
+ * (e.g. CF Access service token pair when the engine domain is
180
+ * locked behind a Cloudflare Access policy).
166
181
  * @returns {Promise<EngineStoreResult>}
167
182
  */
168
183
  export async function engineStore(engineUrl, opts) {
@@ -174,6 +189,7 @@ export async function engineStore(engineUrl, opts) {
174
189
  metadata = {},
175
190
  layerType,
176
191
  actorUserId,
192
+ headers,
177
193
  } = opts || {};
178
194
  if (!clientId) throw new Error("engineStore: clientId required");
179
195
  if (typeof content !== "string") throw new Error("engineStore: content required");
@@ -187,7 +203,7 @@ export async function engineStore(engineUrl, opts) {
187
203
  ...(actorUserId !== undefined ? { actor_user_id: actorUserId } : {}),
188
204
  },
189
205
  };
190
- return fetchEngine(engineUrl, "/store", body);
206
+ return fetchEngine(engineUrl, "/store", body, { headers });
191
207
  }
192
208
 
193
209
  /**
@@ -206,6 +222,8 @@ export async function engineStore(engineUrl, opts) {
206
222
  * @param {number} [opts.limit=10]
207
223
  * @param {number} [opts.minScore=0.3]
208
224
  * @param {object} [opts.metadataFilter] arbitrary equality filter on result metadata
225
+ * @param {Record<string,string>} [opts.headers] forwarded HTTP headers
226
+ * (e.g. CF Access service token pair).
209
227
  * @returns {Promise<{results: EngineSearchHit[]}>}
210
228
  */
211
229
  export async function engineSearch(engineUrl, opts) {
@@ -216,6 +234,7 @@ export async function engineSearch(engineUrl, opts) {
216
234
  limit = DEFAULT_LIMIT,
217
235
  minScore = DEFAULT_MIN_SCORE,
218
236
  metadataFilter,
237
+ headers,
219
238
  } = opts || {};
220
239
  if (!clientId) throw new Error("engineSearch: clientId required");
221
240
  if (typeof query !== "string") throw new Error("engineSearch: query required");
@@ -232,7 +251,7 @@ export async function engineSearch(engineUrl, opts) {
232
251
  ? { metadata_filter: metadataFilter }
233
252
  : {}),
234
253
  };
235
- return fetchEngine(engineUrl, "/search", body);
254
+ return fetchEngine(engineUrl, "/search", body, { headers });
236
255
  }
237
256
 
238
257
  /**
@@ -245,10 +264,12 @@ export async function engineSearch(engineUrl, opts) {
245
264
  * @param {string} opts.clientId
246
265
  * @param {string} [opts.id] forget a single record by engine id
247
266
  * @param {object} [opts.metadataContains] forget all records matching every key=value pair
267
+ * @param {Record<string,string>} [opts.headers] forwarded HTTP headers
268
+ * (e.g. CF Access service token pair).
248
269
  * @returns {Promise<{deleted: number}>}
249
270
  */
250
271
  export async function engineForget(engineUrl, opts) {
251
- const { clientId, id, metadataContains } = opts || {};
272
+ const { clientId, id, metadataContains, headers } = opts || {};
252
273
  if (!clientId) throw new Error("engineForget: clientId required");
253
274
  if (!id && !metadataContains) {
254
275
  throw new Error("engineForget: provide id or metadataContains");
@@ -258,5 +279,5 @@ export async function engineForget(engineUrl, opts) {
258
279
  ...(id ? { id } : {}),
259
280
  ...(metadataContains ? { metadata_contains: metadataContains } : {}),
260
281
  };
261
- return fetchEngine(engineUrl, "/forget", body);
282
+ return fetchEngine(engineUrl, "/forget", body, { headers });
262
283
  }