@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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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