@stigg/agent-sdk-js 0.7.7 → 0.7.8

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/README.md CHANGED
@@ -165,7 +165,7 @@ request.
165
165
  | | Orchestrator | | Orchestrator |
166
166
  | --- | --- | --- | --- |
167
167
  | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/langchain.svg" width="18" alt=""> | [LangChain](docs/langchain.md) | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/langgraph.svg" width="18" alt=""> | [LangGraph](docs/langchain.md#a-hand-built-langgraph) |
168
- | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/google-adk.png" width="18" alt=""> | [Google ADK](docs/guide.md#google-adk-one-plugin-per-runner) | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/vercel.svg" width="18" alt=""> | [Vercel AI SDK](docs/vercel-ai.md) (model calls, for now) |
168
+ | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/google-adk.png" width="18" alt=""> | [Google ADK](docs/guide.md#google-adk-one-plugin-per-runner) | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/vercel.svg" width="18" alt=""> | [Vercel AI SDK](docs/vercel-ai.md) |
169
169
  | | Your own harness, through `stigg.middleware()` | | |
170
170
 
171
171
  Each binding is written against the *shape a vendor returns*, not against its SDK — so installing
@@ -187,7 +187,7 @@ rather than handing back a client that quietly meters nothing.
187
187
  | Meter a gateway you operate | `client.stigg.headers()` out, `stigg.ingestFromHeaders(...)` back |
188
188
  | Govern a LangChain / LangGraph agent graph ([guide](docs/langchain.md)) | `new StiggLangChainCallbackHandler({ stigg, agents, tools })` from `@stigg/agent-sdk-js/langchain` |
189
189
  | Govern a Google ADK agent graph ([guide](docs/guide.md#google-adk-one-plugin-per-runner)) | `new StiggAdkPlugin({ stigg, agents, tools, stepContext })` from `@stigg/agent-sdk-js/adk`, in the Runner's `plugins` |
190
- | Govern a Vercel AI SDK model or agent ([guide](docs/vercel-ai.md)) | `wrapLanguageModel({ model, middleware: stiggVercelMiddleware({ stigg, stiggContext }) })`, a call's own in `providerOptions.stigg` |
190
+ | Govern a Vercel AI SDK model or agent ([guide](docs/vercel-ai.md)) | `wrapLanguageModel({ model, middleware: stiggVercelMiddleware({ stigg, stiggContext, tools }) })`, a call's own in `providerOptions.stigg`; paid tools through `middleware.tools(...)` |
191
191
  | Govern a graph from your own harness | `stigg.middleware({ stiggContext, agents })` |
192
192
 
193
193
  One wrapped client serves every customer and every agent: the context is an argument, given once at
@@ -9,16 +9,26 @@
9
9
  * bindings. A call's own `stiggContext` rides `providerOptions.stigg`, and is taken out before the
10
10
  * provider sees the call. Nothing here imports `ai`: a middleware is a plain object. See
11
11
  * docs/vercel-ai.md.
12
+ *
13
+ * A model middleware never sees a tool run, so the paid tools are governed by `tools()`, which wraps
14
+ * each one's `execute`. A tool call is governed as the model step that asked for it.
12
15
  */
13
16
  import type { StiggAgentSDK } from '../../../stigg-agent-sdk';
14
17
  import type { SessionHooks } from '../../../core/session';
15
- import type { ClientContext, GovernedAgentMiddleware, Measurement } from '../../../types';
18
+ import type { ClientContext, GovernedAgentMiddleware, Measurement, MiddlewareTools } from '../../../types';
19
+ import { type ModelUsage } from './usage';
16
20
  export { VERCEL_AI_METRICS, measurementOf } from './usage';
17
21
  export type { VercelAIMetric, VercelAIMetrics } from './usage';
18
22
  export type StiggVercelMiddlewareOptions = {
19
23
  stigg: StiggAgentSDK;
20
24
  /** Every call's context - typically the agent's `agentId` and what it gates on. A call's `providerOptions.stigg` is laid over it. */
21
25
  stiggContext?: ClientContext;
26
+ /**
27
+ * The paid tools, by tool name - `'*'` for every tool not named, `false` to exempt one. Governed
28
+ * once wrapped by `tools()`. A sub-agent run inside a tool is metered by its own model calls, so
29
+ * give it `false` under `'*'`.
30
+ */
31
+ tools?: MiddlewareTools;
22
32
  hooks?: SessionHooks;
23
33
  };
24
34
  /**
@@ -40,13 +50,30 @@ export type StiggVercelMiddleware = {
40
50
  params: any;
41
51
  model?: any;
42
52
  }): Promise<any>;
53
+ /**
54
+ * The same tools, each paid one's `execute` wrapped: checked before it runs, and reported after it
55
+ * succeeds. A refused tool does not run; it fails with a message that names the tool and why, and
56
+ * `stigg.denial(error)` returns the decision. Tools with no `execute` are returned as they are.
57
+ */
58
+ tools<T extends Record<string, any>>(tools: T): T;
43
59
  /** What this middleware did, and what is still in flight. */
44
60
  readonly stigg: GovernedAgentMiddleware['stigg'];
45
61
  };
46
- export declare function stiggVercelMiddleware({ stigg, stiggContext, hooks, }: StiggVercelMiddlewareOptions): StiggVercelMiddleware;
62
+ export declare function stiggVercelMiddleware({ stigg, stiggContext, tools: toolConfig, hooks, }: StiggVercelMiddlewareOptions): StiggVercelMiddleware;
63
+ type StreamPart = {
64
+ type?: string;
65
+ id?: string;
66
+ modelId?: unknown;
67
+ delta?: unknown;
68
+ usage?: ModelUsage;
69
+ };
47
70
  /**
48
71
  * Calls `done` once, when the stream ends: read to the end, failed, cancelled, or stopped by the
49
72
  * call's `abortSignal` - which is how the AI SDK stops a stream, without cancelling it.
50
73
  * ponytail: a reader that stops with no `abortSignal` is not reported; nothing tells this layer.
51
74
  */
52
- export declare function observeStream(result: any, signal: AbortSignal | undefined, done: (measurement: Measurement | undefined, modelId?: string) => void): void;
75
+ export declare function observeStream(result: any, signal: AbortSignal | undefined, done: (measurement: Measurement | undefined, modelId?: string) => void,
76
+ /** Each part, before the AI SDK reads it - so a tool call is known before its tool can start. */
77
+ onPart?: (part: StreamPart & {
78
+ toolCallId?: unknown;
79
+ }) => void): void;
@@ -3,17 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.measurementOf = exports.VERCEL_AI_METRICS = void 0;
4
4
  exports.stiggVercelMiddleware = stiggVercelMiddleware;
5
5
  exports.observeStream = observeStream;
6
+ const errors_1 = require("../../../errors");
7
+ const hooks_1 = require("../../../core/hooks");
8
+ const agent_middleware_1 = require("../core/agent-middleware");
6
9
  const usage_1 = require("./usage");
7
10
  var usage_2 = require("./usage");
8
11
  Object.defineProperty(exports, "VERCEL_AI_METRICS", { enumerable: true, get: function () { return usage_2.VERCEL_AI_METRICS; } });
9
12
  Object.defineProperty(exports, "measurementOf", { enumerable: true, get: function () { return usage_2.measurementOf; } });
10
13
  const MISSING_CUSTOMER = 'Name it on every call as `providerOptions: { stigg: { customerId } }`, ' +
11
14
  'or give the middleware a default in its `stiggContext: { customerId }`.';
12
- function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
15
+ /** How many asked-for tool calls are remembered. One that never runs - a client-side tool - is dropped past it. */
16
+ const ISSUED_CALLS_LIMIT = 1_000;
17
+ function stiggVercelMiddleware({ stigg, stiggContext, tools: toolConfig, hooks, }) {
13
18
  if (!stigg || typeof stigg.middleware !== 'function') {
14
19
  throw new Error('stiggVercelMiddleware needs `stigg`: the StiggAgentSDK instance to govern through.');
15
20
  }
16
- const governance = stigg.middleware({ stiggContext, hooks, missingCustomer: MISSING_CUSTOMER });
21
+ const governance = stigg.middleware({ stiggContext, tools: toolConfig, hooks, missingCustomer: MISSING_CUSTOMER });
17
22
  // `doGenerate()` takes no arguments: the model is called with what `transformParams` returned.
18
23
  // So the context comes out there, and is found again by that params object.
19
24
  const contexts = new WeakMap();
@@ -27,11 +32,128 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
27
32
  // of the same customer included - never queue behind it. A call with no context of its own falls
28
33
  // back to its customer and agent. `endModel` never rejects.
29
34
  const reporting = new Map();
30
- const callOf = (params) => contexts.get(params) ?? JSON.stringify([stiggContext?.customerId, stiggContext?.agentId]);
35
+ const defaultCall = JSON.stringify([stiggContext?.customerId, stiggContext?.agentId]);
36
+ const callOf = (params) => contexts.get(params) ?? defaultCall;
37
+ /** Waits for a call's stream reports in flight, so its next check sees their spend. */
38
+ const settle = (call) => Promise.all(reporting.get(call) ?? []);
39
+ // A tool's `execute` is handed only the call id, so each tool call a model step asks for is kept
40
+ // by its id - before the AI SDK can start the tool - and taken when it runs.
41
+ // Only for tools `tools()` wrapped: another's call - client-side, run by the provider - never runs
42
+ // here, and would only fill the map.
43
+ // ponytail: keyed by the provider's id alone. First-party providers make each id unique; a
44
+ // backend that reuses ids (`call_0`) across concurrent calls would let one call's entry replace
45
+ // another's - scope the key by call if one shows up. A call `experimental_repairToolCall` renames
46
+ // is not kept either, and is governed as one no step asked for.
47
+ const issued = new Map();
48
+ const wrapped = new Set();
49
+ const ask = (params, part) => {
50
+ if (part?.type !== 'tool-call' || typeof part.toolCallId !== 'string' || part.providerExecuted)
51
+ return;
52
+ if (typeof part.toolName !== 'string' || !wrapped.has(part.toolName))
53
+ return;
54
+ issued.delete(part.toolCallId);
55
+ issued.set(part.toolCallId, { context: contexts.get(params), call: callOf(params) });
56
+ if (issued.size > ISSUED_CALLS_LIMIT)
57
+ issued.delete(issued.keys().next().value);
58
+ };
59
+ /**
60
+ * Said once per tool: a call no model step of this middleware asked for - approved after a pause
61
+ * (`needsApproval` runs it at the start of the next call, before any model step), answered on
62
+ * another instance, or asked by another middleware's model - and whose own context names no
63
+ * `stigg`, is governed by this middleware's own `stiggContext`, and reported for its customer.
64
+ */
65
+ const warned = new Set();
66
+ const warnOnce = (name) => {
67
+ if (warned.has(name))
68
+ return;
69
+ warned.add(name);
70
+ // A logger that throws must not fail the tool it only reports on.
71
+ (0, hooks_1.safely)(() => stigg.config?.logger?.warn(`[stigg] tool "${name}" ran without a model step this middleware saw asking for it, and its ` +
72
+ "context names no `stigg`, so it is governed by the middleware's own stiggContext. An approved " +
73
+ `tool does this: name its customer in \`toolsContext: { ${name}: { stigg: { customerId } } }\`.`), () => undefined);
74
+ };
75
+ /** Checks a tool call as the step that asked for it. Refused: an error the model may read. */
76
+ const startTool = async (name, input, options) => {
77
+ const callId = typeof options?.toolCallId === 'string' ? options.toolCallId : undefined;
78
+ const issuedCall = callId === undefined ? undefined : issued.get(callId);
79
+ if (callId !== undefined)
80
+ issued.delete(callId);
81
+ // No model step asked for it here - an approved tool runs at the start of the next call - so
82
+ // the tool's own context may name who it is for: `toolsContext` (ai 7), `experimental_context` (ai 6).
83
+ const stated = issuedCall ? undefined : toolContextOf(options);
84
+ const asked = issuedCall ?? (stated ? { context: stated, call: stated } : undefined);
85
+ if (!asked)
86
+ warnOnce(name);
87
+ // A stream reports when it ends, and the AI SDK runs its tools before that: the tool's check
88
+ // waits, so it sees the spend of the step that asked for it.
89
+ await settle(asked?.call ?? defaultCall);
90
+ const runId = `${prefix}:tool:${++steps}`;
91
+ try {
92
+ await governance.startTool(runId, { name, callId, input }, { stiggContexts: asked?.context ? [asked.context] : [] });
93
+ }
94
+ catch (err) {
95
+ const decision = (0, errors_1.isAccessDenied)(err);
96
+ if (!decision)
97
+ throw err;
98
+ // The AI SDK hands a failed tool's message to the model, so it says why, not who refused or
99
+ // what is left. The decision rides along as the cause, for `stigg.denial(error)`.
100
+ throw new Error(`${name} is not available: ${decision.reason ?? 'access denied'}.`, { cause: err });
101
+ }
102
+ return runId;
103
+ };
104
+ /**
105
+ * A tool whose `execute` is checked and reported. An `async function*` streams its results, and is
106
+ * reported by its last one, as the AI SDK takes it.
107
+ * ponytail: any other function returns a promise here, since the check is awaited first - so an
108
+ * async iterable it returns is run to its end and answered by its last value, without the
109
+ * preliminary results. Write it as `async function*` to stream them.
110
+ */
111
+ const governTool = (name, tool) => {
112
+ const execute = tool.execute;
113
+ if (Object.prototype.toString.call(execute) === '[object AsyncGeneratorFunction]') {
114
+ return async function* (input, options) {
115
+ const runId = await startTool(name, input, options);
116
+ let output;
117
+ let finished = false;
118
+ try {
119
+ for await (const value of execute.call(tool, input, options)) {
120
+ output = value;
121
+ yield value;
122
+ }
123
+ finished = true;
124
+ }
125
+ finally {
126
+ if (!finished)
127
+ governance.abandon(runId);
128
+ }
129
+ await governance.endTool(runId, output, name);
130
+ };
131
+ }
132
+ return async (input, options) => {
133
+ const runId = await startTool(name, input, options);
134
+ let output;
135
+ try {
136
+ output = await execute.call(tool, input, options);
137
+ if (isAsyncIterable(output)) {
138
+ let last;
139
+ for await (const value of output)
140
+ last = value;
141
+ output = last;
142
+ }
143
+ }
144
+ catch (err) {
145
+ governance.abandon(runId);
146
+ throw err;
147
+ }
148
+ // Awaited, and the AI SDK waits for every tool before the next step: that step's check sees it.
149
+ await governance.endTool(runId, output, name);
150
+ return output;
151
+ };
152
+ };
31
153
  /** Checks the step, as the agent and customer this call names. Refused: `AccessDeniedError`. */
32
154
  const start = async (params, model, event) => {
33
155
  // Once: a call's steps run one after another, so nothing of its own lands while this waits.
34
- await Promise.all(reporting.get(callOf(params)) ?? []);
156
+ await settle(callOf(params));
35
157
  const runId = `${prefix}:${++steps}`;
36
158
  const context = contexts.get(params);
37
159
  // Kept by the middleware until the step ends, so the model the reply names can still land on it.
@@ -42,6 +164,23 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
42
164
  return {
43
165
  specificationVersion: 'v3',
44
166
  stigg: governance.stigg,
167
+ tools(tools) {
168
+ if (!toolConfig || Object.keys(toolConfig).length === 0) {
169
+ throw new Error("stiggVercelMiddleware's tools() needs a `tools` option: the paid tools by name, '*' for the rest.");
170
+ }
171
+ // Only a tool the config governs - by name, else through `'*'` - as the core looks it up. An
172
+ // exempt or unpriced one is left as it is: nothing would check it, so nothing should wait.
173
+ const governed = (name) => {
174
+ const entry = Object.hasOwn(toolConfig, name) ? toolConfig[name] : toolConfig[agent_middleware_1.ALL_TOOLS];
175
+ return entry !== undefined && entry !== false;
176
+ };
177
+ return Object.fromEntries(Object.entries(tools).map(([name, tool]) => {
178
+ if (typeof tool?.execute !== 'function' || !governed(name))
179
+ return [name, tool];
180
+ wrapped.add(name);
181
+ return [name, { ...tool, execute: governTool(name, tool) }];
182
+ }));
183
+ },
45
184
  async transformParams({ params }) {
46
185
  const options = params?.providerOptions;
47
186
  if (!options || typeof options !== 'object' || !('stigg' in options))
@@ -71,6 +210,8 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
71
210
  const responseId = result?.response?.id;
72
211
  const chars = (0, usage_1.contentChars)(result?.content);
73
212
  served(trace, result?.response?.modelId);
213
+ for (const part of Array.isArray(result?.content) ? result.content : [])
214
+ ask(params, part);
74
215
  await governance.endModel(runId, (0, usage_1.measurementOf)(result?.usage, responseId) ?? (chars ? (0, usage_1.estimated)(chars, usage_1.NO_USAGE, responseId) : undefined));
75
216
  return result;
76
217
  },
@@ -98,7 +239,7 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
98
239
  if (pending.size === 0 && reporting.get(call) === pending)
99
240
  reporting.delete(call);
100
241
  });
101
- });
242
+ }, (part) => ask(params, part));
102
243
  return result;
103
244
  },
104
245
  };
@@ -180,7 +321,9 @@ function streamTally() {
180
321
  * call's `abortSignal` - which is how the AI SDK stops a stream, without cancelling it.
181
322
  * ponytail: a reader that stops with no `abortSignal` is not reported; nothing tells this layer.
182
323
  */
183
- function observeStream(result, signal, done) {
324
+ function observeStream(result, signal, done,
325
+ /** Each part, before the AI SDK reads it - so a tool call is known before its tool can start. */
326
+ onPart) {
184
327
  const source = result?.stream;
185
328
  if (typeof source?.getReader !== 'function') {
186
329
  done(undefined);
@@ -214,6 +357,11 @@ function observeStream(result, signal, done) {
214
357
  return;
215
358
  }
216
359
  tally.record(next.value);
360
+ onPart?.(next.value);
361
+ // The AI SDK starts a step's tools on its `finish` part, before the stream closes - which, from
362
+ // an HTTP provider, is a later read. So the step reports here, and its tools' checks see it.
363
+ if (next.value?.type === 'finish')
364
+ report('ended');
217
365
  controller.enqueue(next.value);
218
366
  },
219
367
  async cancel(reason) {
@@ -222,3 +370,17 @@ function observeStream(result, signal, done) {
222
370
  },
223
371
  });
224
372
  }
373
+ const isAsyncIterable = (value) => typeof value?.[Symbol.asyncIterator] === 'function';
374
+ /** The `stigg` a tool's own context states - its `toolsContext` entry on ai 7, `experimental_context` on ai 6. */
375
+ function toolContextOf(options) {
376
+ const context = options?.context?.stigg ?? options?.experimental_context?.stigg;
377
+ if (context === undefined || context === null)
378
+ return undefined;
379
+ if (typeof context !== 'object' || Array.isArray(context)) {
380
+ throw new TypeError(`A tool's context \`stigg\` must be a stiggContext object - got ${typeof context}.`);
381
+ }
382
+ // On ai 6 the context is the whole app's, and `stigg` there may be the SDK instance itself: only
383
+ // a plain object is a stiggContext. Anything else names no one, and the SDK says so.
384
+ const proto = Object.getPrototypeOf(context);
385
+ return proto === Object.prototype || proto === null ? context : undefined;
386
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigg/agent-sdk-js",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "description": "Stigg agent SDK (JS) - run-lifecycle governance for agentic stacks, with examples per layer",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",