@stigg/agent-sdk-js 0.5.0 β 0.6.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.
- package/README.md +48 -0
- package/dist/bindings/orchestrator/core/agent-middleware.d.ts +17 -6
- package/dist/bindings/orchestrator/core/agent-middleware.js +112 -27
- package/dist/bindings/orchestrator/langchain/callback-handler.d.ts +46 -12
- package/dist/bindings/orchestrator/langchain/callback-handler.js +232 -37
- package/dist/core/context.d.ts +5 -0
- package/dist/core/context.js +31 -3
- package/dist/core/session.d.ts +3 -3
- package/dist/core/session.js +3 -3
- package/dist/engine/engine.d.ts +2 -0
- package/dist/engine/node-sdk-engine.d.ts +4 -3
- package/dist/engine/node-sdk-engine.js +19 -4
- package/dist/engine/rest-governance-engine.js +14 -8
- package/dist/integrations/langchain.d.ts +1 -1
- package/dist/integrations/langchain.js +2 -1
- package/dist/stigg-agent-sdk.d.ts +7 -1
- package/dist/stigg-agent-sdk.js +39 -7
- package/dist/types.d.ts +23 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -73,6 +73,54 @@ try {
|
|
|
73
73
|
the shortest path from nothing to working metering is a currency and a `customerId` β no catalog
|
|
74
74
|
modelling first.
|
|
75
75
|
|
|
76
|
+
## πΈοΈ Governing an agent graph
|
|
77
|
+
|
|
78
|
+
A wrapper governs one client. A LangChain or LangGraph graph runs many agents and tools, so it
|
|
79
|
+
gets one handler instead, passed on `invoke` β LangChain hands it to every model and tool call
|
|
80
|
+
beneath, sub-agents included. Each agent is checked and reports its usage as itself. It imports
|
|
81
|
+
`@langchain/core`, an optional peer, so it uses the copy your application already has.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { StiggAgentSDK } from '@stigg/agent-sdk-js';
|
|
85
|
+
import { StiggLangChainCallbackHandler } from '@stigg/agent-sdk-js/langchain';
|
|
86
|
+
|
|
87
|
+
const stigg = new StiggAgentSDK();
|
|
88
|
+
const credits = { currencyId: 'credits' };
|
|
89
|
+
|
|
90
|
+
// Once per customer: each agent an entity with its own budget - what `agentId` below names.
|
|
91
|
+
for (const agentId of ['agent-researcher', 'agent-writer']) {
|
|
92
|
+
await stigg.register({ customerId: 'customer-acme', agentId, entityTypeId: 'agent', budget: { entitlement: credits, limit: 100 } });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stiggHandler = new StiggLangChainCallbackHandler({
|
|
96
|
+
stigg,
|
|
97
|
+
stiggContext: { attributionKey: 'agentId', entitlement: credits }, // the whole graph
|
|
98
|
+
agents: {
|
|
99
|
+
// by the name LangChain knows the agent or node by
|
|
100
|
+
researcher: { stiggContext: { agentId: 'agent-researcher' }, tools: { web_search: credits } },
|
|
101
|
+
writer: { stiggContext: { agentId: 'agent-writer' } },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await graph.invoke(input, {
|
|
106
|
+
callbacks: [stiggHandler],
|
|
107
|
+
metadata: { stiggContext: { customerId: 'customer-acme' } }, // per run
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Both take the same `stiggContext`, merged the same way β only where it attaches differs:
|
|
112
|
+
|
|
113
|
+
| | Wrapper | Agent graph |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| Attaches to | one vendor client | the graph's `invoke` |
|
|
116
|
+
| The whole thing | `wrap(client, { stiggContext })` | `new StiggLangChainCallbackHandler({ stiggContext })` |
|
|
117
|
+
| Per agent | a wrapped client per agent | `agents[name].stiggContext` |
|
|
118
|
+
| Per request | `create({ ..., stiggContext })` | `invoke(input, { metadata: { stiggContext } })` |
|
|
119
|
+
| Tools | not seen | `tools`, checked and metered per call |
|
|
120
|
+
| A refusal | the call throws | the step throws, and the graph decides β a refused sub-agent reads to its lead as a failed tool call |
|
|
121
|
+
|
|
122
|
+
The [LangChain and LangGraph guide](docs/langchain.md) has every configuration.
|
|
123
|
+
|
|
76
124
|
## π§ How it works
|
|
77
125
|
|
|
78
126
|
Three things happen around a call you already make:
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { type ResolvedContext } from '../../../core/context';
|
|
1
|
+
import { type ResolvedContext, type StiggContext } from '../../../core/context';
|
|
2
2
|
import type { GovernanceSession } from '../../../core/session';
|
|
3
3
|
import { type Entitlement, type Measurement, type MiddlewareOptions, type MiddlewareTools, type ToolUsageMapper } from '../../../types';
|
|
4
4
|
/** Who a step is for, as the framework stated it on that step. */
|
|
5
5
|
export type StepAttribution = {
|
|
6
6
|
/** The agent that took the step, by the framework's own name for it. */
|
|
7
7
|
agent?: string;
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The `stiggContext`s the run stated on the way down to this step, outermost first - the run's,
|
|
10
|
+
* then any a node set for its own calls. Each is applied over the agent's context the way `wrap`
|
|
11
|
+
* applies a call's: the customer, dimensions, and anything else a call can override.
|
|
12
|
+
*/
|
|
13
|
+
stiggContexts?: readonly StiggContext[];
|
|
10
14
|
};
|
|
11
15
|
/** A tool call the model issued in its reply: the id the tool step will refer back to, and what it named. */
|
|
12
16
|
export type IssuedToolCall = {
|
|
@@ -34,14 +38,14 @@ export type AgentMiddleware = {
|
|
|
34
38
|
/**
|
|
35
39
|
* After a model call: its measurement is ingested, or the missing one reported. Never throws.
|
|
36
40
|
* `issued` is the tool calls the reply asked for, so a tool step that arrives with only its call
|
|
37
|
-
* id is named, and
|
|
41
|
+
* id is named, and reported for the agent that called it.
|
|
38
42
|
*/
|
|
39
43
|
endModel(runId: string, measurement: Measurement | undefined, issued?: readonly IssuedToolCall[]): Promise<void>;
|
|
40
44
|
/** Before a tool runs: checked when the tool is a paid one, raising when refused. */
|
|
41
45
|
startTool(runId: string, tool: ToolStep, attribution: StepAttribution): Promise<void>;
|
|
42
46
|
/** After a tool succeeded: what its `usage` makes of the call, or one unit. Never throws. */
|
|
43
|
-
endTool(runId: string, output?: unknown): Promise<void>;
|
|
44
|
-
/** A step that failed: forgotten, and
|
|
47
|
+
endTool(runId: string, output?: unknown, name?: string): Promise<void>;
|
|
48
|
+
/** A step that failed: forgotten, and reports nothing - it did not deliver what it charges for. */
|
|
45
49
|
abandon(runId: string): void;
|
|
46
50
|
};
|
|
47
51
|
/** How one tool is governed, its shorthand expanded. */
|
|
@@ -75,6 +79,13 @@ export type AgentMiddlewareConfig = {
|
|
|
75
79
|
agents: ReadonlyMap<string, ResolvedAgent>;
|
|
76
80
|
/** Told once per agent that was not listed, so the caller can say so. */
|
|
77
81
|
onUnlisted(agent: string): void;
|
|
82
|
+
/** Told once per tool whose usage was reported but not checked, because its name came only with its result. */
|
|
83
|
+
onUncheckedTool(tool: string): void;
|
|
84
|
+
/**
|
|
85
|
+
* Told once per kind of tool that ran with no name at all - called directly, so nothing named it
|
|
86
|
+
* and it could not be priced. Possibly a free tool, so a warning rather than a metering failure.
|
|
87
|
+
*/
|
|
88
|
+
onUnnamedTool(kind: string | undefined): void;
|
|
78
89
|
/** Where the caller puts the customer - quoted when a step has none. */
|
|
79
90
|
missingCustomer: string;
|
|
80
91
|
};
|
|
@@ -11,7 +11,7 @@ exports.createAgentMiddleware = createAgentMiddleware;
|
|
|
11
11
|
* knows one vendor; here the middleware governs a graph's steps and a framework binding (the
|
|
12
12
|
* LangChain callback handler, or a harness of your own) knows how its framework announces them.
|
|
13
13
|
*
|
|
14
|
-
* Every step is checked and
|
|
14
|
+
* Every step is checked and reported as the agent that took it, so one agent running dry refuses
|
|
15
15
|
* that agent's steps and nothing else. A refusal is the SDK's own `AccessDeniedError`, thrown from
|
|
16
16
|
* inside the step - the frameworks worth binding to offer hooks that observe a step, not ones that
|
|
17
17
|
* replace its result - and what it means for the run is the graph's to decide. Nothing else throws
|
|
@@ -19,10 +19,11 @@ exports.createAgentMiddleware = createAgentMiddleware;
|
|
|
19
19
|
* `checkErrorAction`.
|
|
20
20
|
*
|
|
21
21
|
* Steps are keyed by the run id the framework gives each one, because the end of a step usually
|
|
22
|
-
* carries nothing else: what the start resolved is what the end
|
|
22
|
+
* carries nothing else: what the start resolved is what the end reports.
|
|
23
23
|
*/
|
|
24
24
|
const errors_1 = require("../../../errors");
|
|
25
25
|
const context_1 = require("../../../core/context");
|
|
26
|
+
const hooks_1 = require("../../../core/hooks");
|
|
26
27
|
const types_1 = require("../../../types");
|
|
27
28
|
/** The key that stands for every tool a configuration does not name. */
|
|
28
29
|
exports.ALL_TOOLS = '*';
|
|
@@ -68,7 +69,7 @@ function resolveAgents(shared, { tools, agents }) {
|
|
|
68
69
|
// into the first. The shared context is refused the same way, in `resolveContext`.
|
|
69
70
|
if (agent.stiggContext?.idempotencyKey !== undefined) {
|
|
70
71
|
throw new Error(`stigg.middleware's agents.${name}.stiggContext cannot set \`idempotencyKey\` - one key shared ` +
|
|
71
|
-
'by every step that agent takes would
|
|
72
|
+
'by every step that agent takes would report the first and discard the rest. Each step is keyed by its own id.');
|
|
72
73
|
}
|
|
73
74
|
resolved.set(name, {
|
|
74
75
|
context: agent.stiggContext ? (0, context_1.overrideContext)(shared, agent.stiggContext) : shared,
|
|
@@ -96,6 +97,13 @@ function toolFor(tools, name) {
|
|
|
96
97
|
*/
|
|
97
98
|
const ISSUED_CALLS_LIMIT = 1_000;
|
|
98
99
|
const STEPS_LIMIT = 10_000;
|
|
100
|
+
/**
|
|
101
|
+
* What deduplicates a tool step's report: the call id the model issued, plus what it reports
|
|
102
|
+
* against - one tool call is one report, and two entitlements on one call must not collide.
|
|
103
|
+
*/
|
|
104
|
+
function toolKey(callId, context, name) {
|
|
105
|
+
return callId ? `${callId}:${context.checkedId ?? name}` : undefined;
|
|
106
|
+
}
|
|
99
107
|
/** Adds to a map that drops its oldest entry past `limit`, and hands back what it dropped. */
|
|
100
108
|
function remember(map, key, value, limit) {
|
|
101
109
|
map.set(key, value);
|
|
@@ -110,22 +118,43 @@ function createAgentMiddleware(session, config) {
|
|
|
110
118
|
const steps = new Map();
|
|
111
119
|
/** The tool calls models issued, by call id - kept for re-runs, until the cap drops them. */
|
|
112
120
|
const issuedCalls = new Map();
|
|
121
|
+
/** Tools that started without a name, by run id - until they end. */
|
|
122
|
+
const unnamedTools = new Map();
|
|
123
|
+
const warnedUnchecked = new Set();
|
|
124
|
+
const warnedUnnamed = new Set();
|
|
113
125
|
const warnedUnlisted = new Set();
|
|
114
126
|
/**
|
|
115
127
|
* The agent a step belongs to. One the configuration does not list - or a step attributed to no
|
|
116
|
-
* agent - resolves to the shared context, so nothing runs
|
|
128
|
+
* agent - resolves to the shared context, so nothing runs unmetered for having been left out.
|
|
117
129
|
*/
|
|
118
130
|
const agentFor = (agent) => {
|
|
119
131
|
const own = agent === undefined ? undefined : config.agents.get(agent);
|
|
120
|
-
|
|
132
|
+
// Capped: the names come from the caller's own runs, so an unbounded set is theirs to grow.
|
|
133
|
+
if (!own &&
|
|
134
|
+
agent !== undefined &&
|
|
135
|
+
config.agents.size > 0 &&
|
|
136
|
+
!warnedUnlisted.has(agent) &&
|
|
137
|
+
warnedUnlisted.size < 100) {
|
|
121
138
|
warnedUnlisted.add(agent);
|
|
122
|
-
config.onUnlisted(agent);
|
|
139
|
+
warn(() => config.onUnlisted(agent));
|
|
123
140
|
}
|
|
124
141
|
return own ?? config.shared;
|
|
125
142
|
};
|
|
126
|
-
/** The context for one step. The customer is required: an empty one would
|
|
127
|
-
const contextFor = (base,
|
|
128
|
-
const
|
|
143
|
+
/** The context for one step. The customer is required: an empty one would report usage for nobody. */
|
|
144
|
+
const contextFor = (base, attribution) => {
|
|
145
|
+
const { agent, stiggContexts = [] } = attribution;
|
|
146
|
+
// A harness from 0.5 still naming the customer here would otherwise be told it named none.
|
|
147
|
+
if ('customerId' in attribution) {
|
|
148
|
+
throw new Error('A step names its customer in `stiggContexts: [{ customerId }]` - `StepAttribution.customerId` was ' +
|
|
149
|
+
'removed in 0.6.');
|
|
150
|
+
}
|
|
151
|
+
// A run spans many steps, so a key of its own would dedupe them all into the first - refused
|
|
152
|
+
// as the shared and the agents' contexts refuse it. Each step is keyed by its own id.
|
|
153
|
+
if (stiggContexts.some((layer) => layer?.idempotencyKey !== undefined)) {
|
|
154
|
+
throw new Error("A run's stiggContext cannot set `idempotencyKey` - one key shared by every step beneath it would " +
|
|
155
|
+
'report the first and discard the rest. Each step is keyed by its own id.');
|
|
156
|
+
}
|
|
157
|
+
const context = stiggContexts.reduce((resolved, override) => (0, context_1.overrideContext)(resolved, override), base);
|
|
129
158
|
if (!context.customerId)
|
|
130
159
|
throw (0, context_1.noCustomer)(config.missingCustomer);
|
|
131
160
|
return agent === undefined ? context : { ...context, agent };
|
|
@@ -138,26 +167,39 @@ function createAgentMiddleware(session, config) {
|
|
|
138
167
|
if (!decision.hasAccess)
|
|
139
168
|
throw new errors_1.AccessDeniedError(decision);
|
|
140
169
|
};
|
|
141
|
-
/** A step in flight. One dropped to make room is never
|
|
170
|
+
/** A step in flight. One dropped to make room is never reported, so that is said, not silent. */
|
|
142
171
|
const rememberStep = (runId, step) => {
|
|
143
172
|
const dropped = remember(steps, runId, step, STEPS_LIMIT);
|
|
144
|
-
if (dropped)
|
|
145
|
-
|
|
146
|
-
|
|
173
|
+
if (dropped)
|
|
174
|
+
reportDropped(dropped.context, 'a step');
|
|
175
|
+
};
|
|
176
|
+
/** An unnamed tool held until its result names it - and said, if it is dropped to make room. */
|
|
177
|
+
const rememberUnnamed = (runId, tool) => {
|
|
178
|
+
const dropped = remember(unnamedTools, runId, tool, STEPS_LIMIT);
|
|
179
|
+
if (dropped)
|
|
180
|
+
reportDropped(dropped.context, 'a tool with no name');
|
|
147
181
|
};
|
|
182
|
+
const reportDropped = (context, what) => session.failedToMeasure(context, new Error(`${what} was still in flight after ${STEPS_LIMIT} newer ones started, so it was dropped unreported`));
|
|
183
|
+
/** The application's warnings are its own code: one that throws must not fail a step. */
|
|
184
|
+
const warn = (say) => (0, hooks_1.safely)(say, () => undefined);
|
|
148
185
|
/** The tool calls a reply asked for, kept until each tool has run. */
|
|
149
186
|
const rememberIssuedCalls = (issued, agent) => {
|
|
150
187
|
for (const call of issued)
|
|
151
188
|
remember(issuedCalls, call.id, { name: call.name, agent }, ISSUED_CALLS_LIMIT);
|
|
152
189
|
};
|
|
153
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* A tool step's context: priced by the tool's own entitlement, when it has one, and labelled with
|
|
192
|
+
* the tool. The tool goes last - a run's `stiggContext` naming another entitlement re-prices its
|
|
193
|
+
* model calls, not the tools it set a price on.
|
|
194
|
+
*/
|
|
154
195
|
const toolContext = (agent, tool, name, attribution) => {
|
|
155
196
|
const eventName = { name: `tool.${name}` };
|
|
156
|
-
const
|
|
157
|
-
|
|
197
|
+
const step = contextFor(agent.context, attribution);
|
|
198
|
+
const priced = (0, context_1.overrideContext)(step, tool.entitlement ? { entitlement: tool.entitlement, eventName } : { eventName });
|
|
199
|
+
return { ...priced, agent: step.agent, tool: name };
|
|
158
200
|
};
|
|
159
|
-
/**
|
|
160
|
-
const
|
|
201
|
+
/** Reports what a tool step that ended used: its `usage`, or one unit. A bad `usage` is a metering failure. */
|
|
202
|
+
const ingestTool = async ({ context, idempotencyKey, tool }, output) => {
|
|
161
203
|
const options = { idempotencyKey };
|
|
162
204
|
if (!tool?.usage) {
|
|
163
205
|
await session.ingest(context, 1, options);
|
|
@@ -174,6 +216,40 @@ function createAgentMiddleware(session, config) {
|
|
|
174
216
|
session.failedToMeasure(context, err);
|
|
175
217
|
}
|
|
176
218
|
};
|
|
219
|
+
/**
|
|
220
|
+
* The step an unnamed tool would have started, now that its result names it - or nothing to
|
|
221
|
+
* report, when it has no name or the name is not governed. It ran unchecked, which is said once
|
|
222
|
+
* per tool: only `'*'` can gate a tool whose name is not known when it starts.
|
|
223
|
+
*/
|
|
224
|
+
const stepFromResult = (runId, name) => {
|
|
225
|
+
const unnamed = unnamedTools.get(runId);
|
|
226
|
+
if (!unnamed)
|
|
227
|
+
return undefined;
|
|
228
|
+
if (name === undefined) {
|
|
229
|
+
// Called directly - no tool call, no ToolMessage - so nothing ever named it. It may well be a
|
|
230
|
+
// free tool, so this is said once per kind of tool rather than raised as a metering failure.
|
|
231
|
+
const kind = unnamed.kind ?? '(unknown)';
|
|
232
|
+
if (!warnedUnnamed.has(kind) && warnedUnnamed.size < 100) {
|
|
233
|
+
warnedUnnamed.add(kind);
|
|
234
|
+
warn(() => config.onUnnamedTool(unnamed.kind));
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
const agent = agentFor(unnamed.owner.agent);
|
|
239
|
+
const tool = toolFor(agent.tools, name);
|
|
240
|
+
if (!tool)
|
|
241
|
+
return undefined;
|
|
242
|
+
if (tool.entitlement && !warnedUnchecked.has(name)) {
|
|
243
|
+
warnedUnchecked.add(name);
|
|
244
|
+
warn(() => config.onUncheckedTool(name));
|
|
245
|
+
}
|
|
246
|
+
const context = toolContext(agent, tool, name, unnamed.owner);
|
|
247
|
+
return {
|
|
248
|
+
context,
|
|
249
|
+
tool: { name, input: unnamed.input, usage: tool.usage, callId: unnamed.callId },
|
|
250
|
+
idempotencyKey: toolKey(unnamed.callId, context, name),
|
|
251
|
+
};
|
|
252
|
+
};
|
|
177
253
|
return {
|
|
178
254
|
async startModel(runId, attribution, eventName) {
|
|
179
255
|
const resolved = contextFor(agentFor(attribution.agent).context, attribution);
|
|
@@ -197,7 +273,7 @@ function createAgentMiddleware(session, config) {
|
|
|
197
273
|
},
|
|
198
274
|
async startTool(runId, { name, callId, kind, input }, attribution) {
|
|
199
275
|
// Never taken, only looked up - the cap drops old ones: a tools node that runs again, before
|
|
200
|
-
// or after the call succeeded, is the same call, owned by the same agent,
|
|
276
|
+
// or after the call succeeded, is the same call, owned by the same agent, reported under one key.
|
|
201
277
|
const issued = callId === undefined ? undefined : issuedCalls.get(callId);
|
|
202
278
|
const named = name ?? issued?.name;
|
|
203
279
|
const toolName = named ?? kind;
|
|
@@ -205,26 +281,35 @@ function createAgentMiddleware(session, config) {
|
|
|
205
281
|
const owner = { ...attribution, agent: issued?.agent ?? attribution.agent };
|
|
206
282
|
const agent = agentFor(owner.agent);
|
|
207
283
|
const tool = toolName ? toolFor(agent.tools, named) : undefined;
|
|
208
|
-
if (!toolName || !tool)
|
|
284
|
+
if (!toolName || !tool) {
|
|
285
|
+
// Nothing to check it against yet - but its result will name it, and a priced tool must
|
|
286
|
+
// not run free because its name was not known when it started.
|
|
287
|
+
// Only when a name could match - and with its context resolved now, so a run with no
|
|
288
|
+
// customer is refused before the tool runs, as a named tool is.
|
|
289
|
+
if (named === undefined && Object.values(agent.tools).some(Boolean)) {
|
|
290
|
+
rememberUnnamed(runId, { owner, context: contextFor(agent.context, owner), input, callId, kind });
|
|
291
|
+
}
|
|
209
292
|
return;
|
|
293
|
+
}
|
|
210
294
|
const context = toolContext(agent, tool, toolName, owner);
|
|
211
295
|
if (tool.entitlement)
|
|
212
296
|
await check(context);
|
|
213
297
|
rememberStep(runId, {
|
|
214
298
|
context,
|
|
215
299
|
tool: { name: toolName, input, usage: tool.usage, callId },
|
|
216
|
-
|
|
217
|
-
idempotencyKey: callId ? `${callId}:${context.checkedId ?? toolName}` : undefined,
|
|
300
|
+
idempotencyKey: toolKey(callId, context, toolName),
|
|
218
301
|
});
|
|
219
302
|
},
|
|
220
|
-
async endTool(runId, output) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return;
|
|
303
|
+
async endTool(runId, output, name) {
|
|
304
|
+
// Started governed, or started unnamed and governed by the name its result carries.
|
|
305
|
+
const step = steps.get(runId) ?? stepFromResult(runId, name);
|
|
224
306
|
steps.delete(runId);
|
|
225
|
-
|
|
307
|
+
unnamedTools.delete(runId);
|
|
308
|
+
if (step)
|
|
309
|
+
await ingestTool(step, output);
|
|
226
310
|
},
|
|
227
311
|
abandon(runId) {
|
|
312
|
+
unnamedTools.delete(runId);
|
|
228
313
|
steps.delete(runId);
|
|
229
314
|
},
|
|
230
315
|
};
|
|
@@ -2,18 +2,24 @@
|
|
|
2
2
|
* The LangChain and LangGraph binding: a callback handler over an agent graph's middleware.
|
|
3
3
|
*
|
|
4
4
|
* const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
|
|
5
|
-
* await agent.invoke(input, { callbacks: [stiggHandler], metadata: {
|
|
5
|
+
* await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggContext: { customerId } } });
|
|
6
6
|
*
|
|
7
|
-
* Everything here is LangChain's protocol - which hook a step starts on, where the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Everything here is LangChain's protocol - which hook a step starts on, where the run's
|
|
8
|
+
* `stiggContext` and the usage are, which agent a step belongs to. Whether a step may run and what it is worth is the
|
|
9
|
+
* middleware's. LangChain hands a callback to every run beneath the `invoke`, sub-agents and
|
|
10
|
+
* LangGraph nodes included, so one handler governs the whole graph.
|
|
11
|
+
*
|
|
12
|
+
* A step belongs to the nearest run above it named for a configured agent: a `createAgent` or
|
|
13
|
+
* `createReactAgent` by its `name`, a hand-built graph's node by its own. That is LangChain's run
|
|
14
|
+
* tree, not metadata - `lc_agent_name` in metadata is inherited by an unnamed sub-agent, carried
|
|
15
|
+
* into a sub-agent by a tool that passes its own config, and settable by whoever sends the run's
|
|
16
|
+
* metadata, so it would charge the wrong agent or let a client pick whose budget pays.
|
|
11
17
|
*
|
|
12
18
|
* It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
|
|
13
19
|
* an async handler in the background unless `awaitHandlers` is set too, so both are set - through
|
|
14
20
|
* the constructor, because `awaitHandlers` is derived inside `super()`. And LangChain copies a
|
|
15
21
|
* handler (`new this.constructor(this)`) on its way into a nested run, so a copy shares the
|
|
16
|
-
* original's middleware rather than billing a sub-agent to a fresh session.
|
|
22
|
+
* original's middleware and run tree rather than billing a sub-agent to a fresh session.
|
|
17
23
|
*
|
|
18
24
|
* The one file in `src/` that imports LangChain, reached only through the
|
|
19
25
|
* `@stigg/agent-sdk-js/langchain` entry.
|
|
@@ -22,14 +28,24 @@ import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
|
|
22
28
|
import type { StiggAgentSDK } from '../../../stigg-agent-sdk';
|
|
23
29
|
import type { GovernedAgentMiddleware, MiddlewareOptions } from '../../../types';
|
|
24
30
|
/**
|
|
25
|
-
* The metadata key a
|
|
26
|
-
*
|
|
27
|
-
*
|
|
31
|
+
* The metadata key a run states its `stiggContext` under, on `invoke(input, { metadata })` - the
|
|
32
|
+
* same override `wrap` takes per call, applied over the handler's and the agent's. On `invoke` it
|
|
33
|
+
* names only the customer and `correlationId` (`RUN_FIELDS`); a node's own config may state more.
|
|
34
|
+
* On LangGraph Platform the client sends `metadata`, so set the customer in your auth handler,
|
|
35
|
+
* never from the request.
|
|
28
36
|
*/
|
|
37
|
+
export declare const CONTEXT_METADATA_KEY = "stiggContext";
|
|
38
|
+
/** @deprecated The customer alone, from before a run took a whole `stiggContext`. Read as `stiggContext.customerId`. */
|
|
29
39
|
export declare const CUSTOMER_METADATA_KEY = "stiggCustomerId";
|
|
30
40
|
export type StiggLangChainCallbackOptions = MiddlewareOptions & {
|
|
31
41
|
/** The SDK this handler governs through - one per environment, shared with everything else. */
|
|
32
42
|
stigg: StiggAgentSDK;
|
|
43
|
+
/**
|
|
44
|
+
* The agent the top-level run is - one of `agents`. Set it when the graph's entry point is itself
|
|
45
|
+
* an agent: the caller names the top-level run (`invoke(input, { runName })`), so without this its
|
|
46
|
+
* name would let them pick whose budget pays, or step outside any agent's.
|
|
47
|
+
*/
|
|
48
|
+
rootAgent?: string;
|
|
33
49
|
};
|
|
34
50
|
export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
|
|
35
51
|
name: string;
|
|
@@ -39,12 +55,30 @@ export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
|
|
|
39
55
|
* given (`stigg.flush()`), and reported through the `hooks` it was configured with.
|
|
40
56
|
*/
|
|
41
57
|
protected readonly middleware: GovernedAgentMiddleware;
|
|
58
|
+
private readonly runs;
|
|
42
59
|
constructor(options: StiggLangChainCallbackOptions | StiggLangChainCallbackHandler);
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
handleChainStart(...args: Parameters<NonNullable<BaseCallbackHandler['handleChainStart']>>): void;
|
|
61
|
+
handleChainEnd(_outputs: unknown, runId: string): void;
|
|
62
|
+
handleChainError(_err: unknown, runId: string): void;
|
|
63
|
+
handleChatModelStart(_llm: unknown, _messages: unknown, runId: string, parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
|
|
64
|
+
handleLLMStart(_llm: unknown, _prompts: unknown, runId: string, parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
|
|
45
65
|
handleLLMEnd(output: unknown, runId: string): Promise<void>;
|
|
46
66
|
handleLLMError(_err: unknown, runId: string): void;
|
|
47
|
-
handleToolStart(tool: unknown, input: string, runId: string,
|
|
67
|
+
handleToolStart(tool: unknown, input: string, runId: string, parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): Promise<void>;
|
|
48
68
|
handleToolEnd(output: unknown, runId: string): Promise<void>;
|
|
49
69
|
handleToolError(_err: unknown, runId: string): void;
|
|
70
|
+
/**
|
|
71
|
+
* Who a step is for: the configured agent above it in the run tree, and the `stiggContext`s the
|
|
72
|
+
* run stated on the way down. With no agent above it, a name from metadata is passed on only as a
|
|
73
|
+
* label for the "not listed" warning - never a configured agent's name, which the tree alone can
|
|
74
|
+
* confer.
|
|
75
|
+
*/
|
|
76
|
+
private attributionOf;
|
|
77
|
+
/**
|
|
78
|
+
* The `stiggContext` a run's metadata states, with the customer the deprecated key names. That key
|
|
79
|
+
* is what a server's auth handler was told to set, so it is the one trusted: a `stiggContext`
|
|
80
|
+
* naming another customer beside it is refused rather than charged to someone else. A run with no
|
|
81
|
+
* parent - `invoke`'s own metadata - states only `RUN_FIELDS`, with the deprecated key or without.
|
|
82
|
+
*/
|
|
83
|
+
private stiggContextOf;
|
|
50
84
|
}
|
|
@@ -1,43 +1,142 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.StiggLangChainCallbackHandler = exports.CUSTOMER_METADATA_KEY = void 0;
|
|
3
|
+
exports.StiggLangChainCallbackHandler = exports.CUSTOMER_METADATA_KEY = exports.CONTEXT_METADATA_KEY = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* The LangChain and LangGraph binding: a callback handler over an agent graph's middleware.
|
|
6
6
|
*
|
|
7
7
|
* const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
|
|
8
|
-
* await agent.invoke(input, { callbacks: [stiggHandler], metadata: {
|
|
8
|
+
* await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggContext: { customerId } } });
|
|
9
9
|
*
|
|
10
|
-
* Everything here is LangChain's protocol - which hook a step starts on, where the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Everything here is LangChain's protocol - which hook a step starts on, where the run's
|
|
11
|
+
* `stiggContext` and the usage are, which agent a step belongs to. Whether a step may run and what it is worth is the
|
|
12
|
+
* middleware's. LangChain hands a callback to every run beneath the `invoke`, sub-agents and
|
|
13
|
+
* LangGraph nodes included, so one handler governs the whole graph.
|
|
14
|
+
*
|
|
15
|
+
* A step belongs to the nearest run above it named for a configured agent: a `createAgent` or
|
|
16
|
+
* `createReactAgent` by its `name`, a hand-built graph's node by its own. That is LangChain's run
|
|
17
|
+
* tree, not metadata - `lc_agent_name` in metadata is inherited by an unnamed sub-agent, carried
|
|
18
|
+
* into a sub-agent by a tool that passes its own config, and settable by whoever sends the run's
|
|
19
|
+
* metadata, so it would charge the wrong agent or let a client pick whose budget pays.
|
|
14
20
|
*
|
|
15
21
|
* It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
|
|
16
22
|
* an async handler in the background unless `awaitHandlers` is set too, so both are set - through
|
|
17
23
|
* the constructor, because `awaitHandlers` is derived inside `super()`. And LangChain copies a
|
|
18
24
|
* handler (`new this.constructor(this)`) on its way into a nested run, so a copy shares the
|
|
19
|
-
* original's middleware rather than billing a sub-agent to a fresh session.
|
|
25
|
+
* original's middleware and run tree rather than billing a sub-agent to a fresh session.
|
|
20
26
|
*
|
|
21
27
|
* The one file in `src/` that imports LangChain, reached only through the
|
|
22
28
|
* `@stigg/agent-sdk-js/langchain` entry.
|
|
23
29
|
*/
|
|
24
30
|
const base_1 = require("@langchain/core/callbacks/base");
|
|
31
|
+
const hooks_1 = require("../../../core/hooks");
|
|
25
32
|
const token_usage_1 = require("../../shared/token-usage");
|
|
26
33
|
/**
|
|
27
|
-
* The metadata key a
|
|
28
|
-
*
|
|
29
|
-
*
|
|
34
|
+
* The metadata key a run states its `stiggContext` under, on `invoke(input, { metadata })` - the
|
|
35
|
+
* same override `wrap` takes per call, applied over the handler's and the agent's. On `invoke` it
|
|
36
|
+
* names only the customer and `correlationId` (`RUN_FIELDS`); a node's own config may state more.
|
|
37
|
+
* On LangGraph Platform the client sends `metadata`, so set the customer in your auth handler,
|
|
38
|
+
* never from the request.
|
|
39
|
+
*/
|
|
40
|
+
exports.CONTEXT_METADATA_KEY = 'stiggContext';
|
|
41
|
+
/**
|
|
42
|
+
* What the `stiggContext` of a run with no parent - `invoke`'s own metadata - may state. On LangGraph
|
|
43
|
+
* Platform the request's client writes that metadata, so it names who the run is for and nothing that
|
|
44
|
+
* prices it or says which agent it is. The rest belongs on the handler, under `agents`, or in a
|
|
45
|
+
* node's own config.
|
|
30
46
|
*/
|
|
47
|
+
const RUN_FIELDS = new Set(['customerId', 'correlationId']);
|
|
48
|
+
/** @deprecated The customer alone, from before a run took a whole `stiggContext`. Read as `stiggContext.customerId`. */
|
|
31
49
|
exports.CUSTOMER_METADATA_KEY = 'stiggCustomerId';
|
|
32
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* What `createAgent({ name })` and LangGraph stamp in metadata. Never what a step is reported as -
|
|
52
|
+
* see the top of this file - only the label an unlisted agent is reported under.
|
|
53
|
+
*/
|
|
33
54
|
const AGENT_METADATA_KEY = 'lc_agent_name';
|
|
34
|
-
/** What LangGraph stamps on every step: the graph node it runs in - the agent, in a hand-built graph. */
|
|
35
55
|
const NODE_METADATA_KEY = 'langgraph_node';
|
|
56
|
+
/** How many runs can be open at once before the oldest is forgotten - a run whose end never came. */
|
|
57
|
+
const OPEN_RUNS_LIMIT = 10_000;
|
|
36
58
|
/** The counters LangChain's `usage_metadata` states at the top level, reported under its own names. */
|
|
37
59
|
const LANGCHAIN_METRICS = ['input_tokens', 'output_tokens', 'total_tokens'];
|
|
38
60
|
/** Where a caller puts the customer, for the refusal a step with none raises. */
|
|
39
|
-
const MISSING_CUSTOMER = `Name it on every run as \`invoke(input, { metadata: { ${exports.
|
|
61
|
+
const MISSING_CUSTOMER = `Name it on every run as \`invoke(input, { metadata: { ${exports.CONTEXT_METADATA_KEY}: { customerId } } })\`, ` +
|
|
40
62
|
'or give the handler a default in its `stiggContext: { customerId }`.';
|
|
63
|
+
/**
|
|
64
|
+
* The runs LangChain has open, each with its name and parent, so a step can be traced up to the
|
|
65
|
+
* agent it belongs to. Shared by a handler and every copy LangChain makes of it.
|
|
66
|
+
*/
|
|
67
|
+
class RunTree {
|
|
68
|
+
agents;
|
|
69
|
+
rootAgent;
|
|
70
|
+
warn;
|
|
71
|
+
open = new Map();
|
|
72
|
+
warnedFull = false;
|
|
73
|
+
warned = new Set();
|
|
74
|
+
/**
|
|
75
|
+
* `rootAgent`, when set, is what a top-level run is, whatever it was named: the caller names that
|
|
76
|
+
* run (`invoke(input, { runName })`), so its name alone would let them pick whose budget pays.
|
|
77
|
+
* `warn` is the SDK's logger, for what is said once.
|
|
78
|
+
*/
|
|
79
|
+
constructor(agents, rootAgent, warn) {
|
|
80
|
+
this.agents = agents;
|
|
81
|
+
this.rootAgent = rootAgent;
|
|
82
|
+
this.warn = warn;
|
|
83
|
+
}
|
|
84
|
+
start(runId, parent, name, stiggContext) {
|
|
85
|
+
this.open.set(runId, {
|
|
86
|
+
name: parent === undefined && this.rootAgent ? this.rootAgent : name,
|
|
87
|
+
parent,
|
|
88
|
+
stiggContext,
|
|
89
|
+
});
|
|
90
|
+
if (this.open.size <= OPEN_RUNS_LIMIT)
|
|
91
|
+
return;
|
|
92
|
+
// A forgotten run breaks the walk for the steps beneath it: they fall back to the shared
|
|
93
|
+
// context - still checked and reported, but not as their agent - so that is said.
|
|
94
|
+
this.open.delete(this.open.keys().next().value);
|
|
95
|
+
if (!this.warnedFull) {
|
|
96
|
+
this.warnedFull = true;
|
|
97
|
+
this.warnOnce(`[stigg] more than ${OPEN_RUNS_LIMIT} LangChain runs are open at once, so the oldest are being ` +
|
|
98
|
+
'forgotten: a step beneath one is governed by the shared context rather than as its agent.');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Says a thing once per process - and never fails a step for the logger's sake. */
|
|
102
|
+
warnOnce(message) {
|
|
103
|
+
if (this.warned.has(message) || this.warned.size >= 100)
|
|
104
|
+
return;
|
|
105
|
+
this.warned.add(message);
|
|
106
|
+
(0, hooks_1.safely)(() => this.warn(message), () => undefined);
|
|
107
|
+
}
|
|
108
|
+
end(runId) {
|
|
109
|
+
this.open.delete(runId);
|
|
110
|
+
}
|
|
111
|
+
/** The agent a run belongs to: itself when it is one, otherwise whatever its parent belongs to. */
|
|
112
|
+
agentAt(runId) {
|
|
113
|
+
const run = runId === undefined ? undefined : this.open.get(runId);
|
|
114
|
+
if (!run)
|
|
115
|
+
return undefined; // the top of the tree, or a run this handler never saw start
|
|
116
|
+
if (run.name !== undefined && this.isAgent(run.name))
|
|
117
|
+
return run.name;
|
|
118
|
+
return this.agentAt(run.parent);
|
|
119
|
+
}
|
|
120
|
+
isAgent(name) {
|
|
121
|
+
return this.agents.has(name);
|
|
122
|
+
}
|
|
123
|
+
isOpen(runId) {
|
|
124
|
+
return this.open.has(runId);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The `stiggContext`s stated from the top of the tree down to a run, outermost first. LangChain
|
|
128
|
+
* merges `metadata` one key deep, so a node that sets its own `stiggContext` replaces the run's
|
|
129
|
+
* rather than adding to it - walking the tree puts the run's back underneath. A context a run
|
|
130
|
+
* merely inherited is the same object as its parent's, and is applied once.
|
|
131
|
+
*/
|
|
132
|
+
stiggContextsAt(runId) {
|
|
133
|
+
const run = runId === undefined ? undefined : this.open.get(runId);
|
|
134
|
+
if (!run)
|
|
135
|
+
return [];
|
|
136
|
+
const above = this.stiggContextsAt(run.parent);
|
|
137
|
+
return run.stiggContext && run.stiggContext !== above.at(-1) ? [...above, run.stiggContext] : above;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
41
140
|
class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
|
|
42
141
|
name = 'StiggLangChainCallbackHandler';
|
|
43
142
|
/**
|
|
@@ -46,63 +145,139 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
|
|
|
46
145
|
* given (`stigg.flush()`), and reported through the `hooks` it was configured with.
|
|
47
146
|
*/
|
|
48
147
|
middleware;
|
|
148
|
+
runs;
|
|
49
149
|
constructor(options) {
|
|
50
150
|
super({ raiseError: true, _awaitHandler: true });
|
|
51
151
|
if (options instanceof StiggLangChainCallbackHandler) {
|
|
52
|
-
// LangChain's own copy, on its way into a nested run: the same graph, so the same
|
|
152
|
+
// LangChain's own copy, on its way into a nested run: the same graph, so the same state.
|
|
53
153
|
this.middleware = options.middleware;
|
|
154
|
+
this.runs = options.runs;
|
|
54
155
|
return;
|
|
55
156
|
}
|
|
56
|
-
const { stigg, ...middlewareOptions } = options;
|
|
157
|
+
const { stigg, rootAgent, ...middlewareOptions } = options;
|
|
158
|
+
if (rootAgent !== undefined && !options.agents?.[rootAgent]) {
|
|
159
|
+
throw new Error(`\`rootAgent\` names "${rootAgent}", which \`agents\` does not list - it must be one of them.`);
|
|
160
|
+
}
|
|
57
161
|
if (!stigg || typeof stigg.middleware !== 'function') {
|
|
58
162
|
throw new Error('`StiggLangChainCallbackHandler` needs `stigg`: the `StiggAgentSDK` it governs through.');
|
|
59
163
|
}
|
|
60
164
|
this.middleware = stigg.middleware({ ...middlewareOptions, missingCustomer: MISSING_CUSTOMER });
|
|
165
|
+
this.runs = new RunTree(new Set(Object.keys(options.agents ?? {})), rootAgent, (message) => stigg.config.logger.warn(message));
|
|
61
166
|
}
|
|
62
|
-
|
|
63
|
-
|
|
167
|
+
// The declared signature puts `runType` fourth and `parentRunId` last, but the callback manager
|
|
168
|
+
// calls this with `parentRunId` fourth and `runName` eighth - so it is read by what is passed.
|
|
169
|
+
// True of `@langchain/core` 1.2; the attribution tests run against the real manager, so a release
|
|
170
|
+
// that changes either side fails them.
|
|
171
|
+
handleChainStart(...args) {
|
|
172
|
+
const [, , runId, parentRunId, , metadata, , runName] = args;
|
|
173
|
+
this.runs.start(runId, parentRunId, runName, this.stiggContextOf(metadata, parentRunId));
|
|
174
|
+
}
|
|
175
|
+
handleChainEnd(_outputs, runId) {
|
|
176
|
+
this.runs.end(runId);
|
|
177
|
+
}
|
|
178
|
+
handleChainError(_err, runId) {
|
|
179
|
+
this.runs.end(runId);
|
|
180
|
+
}
|
|
181
|
+
handleChatModelStart(_llm, _messages, runId, parentRunId, _extraParams, _tags, metadata) {
|
|
182
|
+
return this.middleware.startModel(runId, this.attributionOf(parentRunId, metadata), 'langchain.chat_model');
|
|
64
183
|
}
|
|
65
184
|
// The completion-model path, for an LLM that is not a chat model. Same gate, same meter.
|
|
66
|
-
handleLLMStart(_llm, _prompts, runId,
|
|
67
|
-
return this.middleware.startModel(runId, attributionOf(metadata), 'langchain.llm');
|
|
185
|
+
handleLLMStart(_llm, _prompts, runId, parentRunId, _extraParams, _tags, metadata) {
|
|
186
|
+
return this.middleware.startModel(runId, this.attributionOf(parentRunId, metadata), 'langchain.llm');
|
|
68
187
|
}
|
|
69
188
|
handleLLMEnd(output, runId) {
|
|
70
|
-
const
|
|
189
|
+
const result = output;
|
|
190
|
+
const message = result?.generations?.[0]?.[0]?.message;
|
|
71
191
|
const issued = (message?.tool_calls ?? []).flatMap((call) => call.id && call.name ? [{ id: call.id, name: call.name }] : []);
|
|
72
|
-
return this.middleware.endModel(runId, measurementOf(message), issued);
|
|
192
|
+
return this.middleware.endModel(runId, measurementOf(message) ?? completionMeasurement(result, runId), issued);
|
|
73
193
|
}
|
|
74
194
|
handleLLMError(_err, runId) {
|
|
75
195
|
this.middleware.abandon(runId);
|
|
76
196
|
}
|
|
77
|
-
handleToolStart(tool, input, runId,
|
|
197
|
+
handleToolStart(tool, input, runId, parentRunId, _tags, metadata, runName, toolCallId) {
|
|
78
198
|
// `runName` is set only for a tool built with `tool()`; a class-based one - `TavilySearch`,
|
|
79
199
|
// most packaged integrations - starts with none, and a serialized form that names only its
|
|
80
200
|
// class. The middleware names it from the model's own tool call, or by that class when no
|
|
81
201
|
// model called it.
|
|
82
202
|
const serialized = tool;
|
|
83
|
-
|
|
203
|
+
const name = runName ?? serialized?.name;
|
|
204
|
+
// A tool is a run too: a sub-agent it starts belongs below it in the tree.
|
|
205
|
+
this.runs.start(runId, parentRunId, name, this.stiggContextOf(metadata, parentRunId));
|
|
206
|
+
return this.middleware.startTool(runId, { name, callId: toolCallId, kind: serialized?.id?.at(-1), input: decoded(input) }, this.attributionOf(parentRunId, metadata));
|
|
84
207
|
}
|
|
85
208
|
handleToolEnd(output, runId) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
209
|
+
this.runs.end(runId);
|
|
210
|
+
// A tool the model called returns a `ToolMessage`, its result serialized into `content` - and
|
|
211
|
+
// its name, for a tool that started without one.
|
|
212
|
+
const message = isToolMessage(output) ? output : undefined;
|
|
213
|
+
return this.middleware.endTool(runId, decoded(message ? message.content : output), message?.name);
|
|
89
214
|
}
|
|
90
215
|
handleToolError(_err, runId) {
|
|
216
|
+
this.runs.end(runId);
|
|
91
217
|
this.middleware.abandon(runId);
|
|
92
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Who a step is for: the configured agent above it in the run tree, and the `stiggContext`s the
|
|
221
|
+
* run stated on the way down. With no agent above it, a name from metadata is passed on only as a
|
|
222
|
+
* label for the "not listed" warning - never a configured agent's name, which the tree alone can
|
|
223
|
+
* confer.
|
|
224
|
+
*/
|
|
225
|
+
attributionOf(parentRunId, metadata) {
|
|
226
|
+
const label = metadata?.[AGENT_METADATA_KEY] ?? metadata?.[NODE_METADATA_KEY];
|
|
227
|
+
const unlisted = typeof label === 'string' && !this.runs.isAgent(label) ? label : undefined;
|
|
228
|
+
const agent = this.runs.agentAt(parentRunId);
|
|
229
|
+
// Metadata naming a configured agent the tree does not find is a misconfiguration - a subgraph
|
|
230
|
+
// node named apart from its agent, a top-level agent with no `rootAgent` - or a caller's attempt.
|
|
231
|
+
if (agent === undefined && typeof label === 'string' && this.runs.isAgent(label)) {
|
|
232
|
+
this.runs.warnOnce(`[stigg] a step's metadata names agent "${label}", but no run above it is named "${label}" - it is ` +
|
|
233
|
+
'governed by the shared context. Name the node or the agent after it, or set `rootAgent` if it is ' +
|
|
234
|
+
'the top-level agent.');
|
|
235
|
+
}
|
|
236
|
+
const above = this.runs.stiggContextsAt(parentRunId);
|
|
237
|
+
const own = this.stiggContextOf(metadata, parentRunId);
|
|
238
|
+
// A step's own `stiggContext` replaced the run's in its metadata; only the tree puts the run's
|
|
239
|
+
// back. A parent this handler never saw start - the handler given to a model rather than to
|
|
240
|
+
// `invoke` - leaves the step with its own context alone, and the run's customer gone.
|
|
241
|
+
if (own && own.customerId === undefined && parentRunId !== undefined && !this.runs.isOpen(parentRunId)) {
|
|
242
|
+
this.runs.warnOnce("[stigg] a step's own stiggContext names no customer, and the run above it was never seen by this " +
|
|
243
|
+
"handler - so the run's stiggContext is lost. Pass the handler at `invoke` (`callbacks: [handler]`), " +
|
|
244
|
+
'not to a model or tool.');
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
agent: agent ?? unlisted,
|
|
248
|
+
stiggContexts: own && own !== above.at(-1) ? [...above, own] : above,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The `stiggContext` a run's metadata states, with the customer the deprecated key names. That key
|
|
253
|
+
* is what a server's auth handler was told to set, so it is the one trusted: a `stiggContext`
|
|
254
|
+
* naming another customer beside it is refused rather than charged to someone else. A run with no
|
|
255
|
+
* parent - `invoke`'s own metadata - states only `RUN_FIELDS`, with the deprecated key or without.
|
|
256
|
+
*/
|
|
257
|
+
stiggContextOf(metadata, parentRunId) {
|
|
258
|
+
const stated = metadata?.[exports.CONTEXT_METADATA_KEY];
|
|
259
|
+
const customerId = metadata?.[exports.CUSTOMER_METADATA_KEY];
|
|
260
|
+
if (stated !== undefined && (typeof stated !== 'object' || stated === null || Array.isArray(stated))) {
|
|
261
|
+
throw new Error(`metadata.${exports.CONTEXT_METADATA_KEY} must be a \`stiggContext\` object, not ${String(stated)}.`);
|
|
262
|
+
}
|
|
263
|
+
const unlisted = parentRunId === undefined && stated ? Object.keys(stated).filter((key) => !RUN_FIELDS.has(key)) : [];
|
|
264
|
+
if (unlisted.length) {
|
|
265
|
+
throw new Error(`metadata.${exports.CONTEXT_METADATA_KEY} on a run's \`invoke\` names only the customer and \`correlationId\`, not ` +
|
|
266
|
+
`${unlisted.join(', ')} - a client can write that metadata. Set the rest on the handler, under \`agents\`, ` +
|
|
267
|
+
"or in a node's own config.");
|
|
268
|
+
}
|
|
269
|
+
if (typeof customerId !== 'string' || !customerId)
|
|
270
|
+
return stated;
|
|
271
|
+
this.runs.warnOnce(`[stigg] metadata.${exports.CUSTOMER_METADATA_KEY} is deprecated - pass \`metadata: { ${exports.CONTEXT_METADATA_KEY}: { customerId } }\` instead.`);
|
|
272
|
+
const context = stated;
|
|
273
|
+
if (context?.customerId !== undefined && context.customerId !== customerId) {
|
|
274
|
+
throw new Error(`metadata.${exports.CONTEXT_METADATA_KEY}.customerId (${String(context.customerId)}) differs from metadata.${exports.CUSTOMER_METADATA_KEY} ` +
|
|
275
|
+
`(${customerId}) - name the customer once, in metadata.${exports.CONTEXT_METADATA_KEY}.`);
|
|
276
|
+
}
|
|
277
|
+
return { ...context, customerId };
|
|
278
|
+
}
|
|
93
279
|
}
|
|
94
280
|
exports.StiggLangChainCallbackHandler = StiggLangChainCallbackHandler;
|
|
95
|
-
/** Who a LangChain step is for: the agent (its `createAgent` name, or its graph node), and the customer. */
|
|
96
|
-
function attributionOf(metadata) {
|
|
97
|
-
// `createAgent`'s name first: inside one, the node is an internal one (`model_request`). In a
|
|
98
|
-
// hand-built `StateGraph` there is no agent name, and the node is the agent.
|
|
99
|
-
const agent = metadata?.[AGENT_METADATA_KEY] ?? metadata?.[NODE_METADATA_KEY];
|
|
100
|
-
const customerId = metadata?.[exports.CUSTOMER_METADATA_KEY];
|
|
101
|
-
return {
|
|
102
|
-
agent: typeof agent === 'string' ? agent : undefined,
|
|
103
|
-
customerId: typeof customerId === 'string' && customerId ? customerId : undefined,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
281
|
/** LangChain's `ToolMessage`, told by shape - the class is the application's copy, not ours. */
|
|
107
282
|
function isToolMessage(value) {
|
|
108
283
|
return (typeof value === 'object' &&
|
|
@@ -139,6 +314,26 @@ function measurementOf(message) {
|
|
|
139
314
|
...(message.id ? { idempotencyKey: message.id } : {}),
|
|
140
315
|
};
|
|
141
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* A completion model's usage, which LangChain reports on `llmOutput.tokenUsage` rather than on a
|
|
319
|
+
* message - in camelCase, so it is mapped onto the same names a chat model reports under.
|
|
320
|
+
*/
|
|
321
|
+
function completionMeasurement(result, runId) {
|
|
322
|
+
const usage = result?.llmOutput?.tokenUsage;
|
|
323
|
+
if (!usage)
|
|
324
|
+
return undefined;
|
|
325
|
+
const raw = {
|
|
326
|
+
input_tokens: usage.promptTokens,
|
|
327
|
+
output_tokens: usage.completionTokens,
|
|
328
|
+
total_tokens: usage.totalTokens,
|
|
329
|
+
};
|
|
330
|
+
// An empty `tokenUsage` - a streamed completion's - says nothing, and is no measurement: reported
|
|
331
|
+
// as zero it would look like a call that cost nothing, rather than one nobody measured.
|
|
332
|
+
if (!Object.values(raw).some((value) => typeof value === 'number'))
|
|
333
|
+
return undefined;
|
|
334
|
+
// No provider message id to key on, so the run's own: the same run reported twice is one report.
|
|
335
|
+
return { metrics: pickNumbers(raw, LANGCHAIN_METRICS), usage: (0, token_usage_1.normalizeUsage)(raw), raw, idempotencyKey: runId };
|
|
336
|
+
}
|
|
142
337
|
/** The named counters that are numbers, as they were stated. */
|
|
143
338
|
function pickNumbers(source, names) {
|
|
144
339
|
const metrics = {};
|
package/dist/core/context.d.ts
CHANGED
|
@@ -144,6 +144,11 @@ export declare const CONTEXT_DEFAULTS: {
|
|
|
144
144
|
readonly checkErrorAction: "allow";
|
|
145
145
|
readonly ingestMode: "async";
|
|
146
146
|
};
|
|
147
|
+
/**
|
|
148
|
+
* A plan id as given, trimmed - or a refusal when it was given blank. Absent stays absent: no
|
|
149
|
+
* plan is a legal choice, while an empty one is a typo that would provision nobody onto nothing.
|
|
150
|
+
*/
|
|
151
|
+
export declare function planId(value: unknown, field?: string): string | undefined;
|
|
147
152
|
/**
|
|
148
153
|
* The one field a call cannot proceed without, refused the same way from every binding. Only where
|
|
149
154
|
* to put the customer differs, so the remedy is the caller's to word.
|
package/dist/core/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.addMetrics = exports.isEnforced = exports.noCustomer = exports.CONTEXT_DEFAULTS = void 0;
|
|
4
|
+
exports.planId = planId;
|
|
4
5
|
exports.resolveContext = resolveContext;
|
|
5
6
|
exports.overrideContext = overrideContext;
|
|
6
7
|
/**
|
|
@@ -62,12 +63,14 @@ function agentDimension(context) {
|
|
|
62
63
|
* A plan id as given, trimmed - or a refusal when it was given blank. Absent stays absent: no
|
|
63
64
|
* plan is a legal choice, while an empty one is a typo that would provision nobody onto nothing.
|
|
64
65
|
*/
|
|
65
|
-
function planId(value) {
|
|
66
|
+
function planId(value, field = 'stiggContext.defaultPlanId') {
|
|
66
67
|
if (value === undefined)
|
|
67
68
|
return undefined;
|
|
68
|
-
|
|
69
|
+
if (typeof value !== 'string')
|
|
70
|
+
throw new Error(`${field} must be a plan id string, not ${typeof value}.`);
|
|
71
|
+
const named = value.trim();
|
|
69
72
|
if (!named) {
|
|
70
|
-
throw new Error(
|
|
73
|
+
throw new Error(`${field} is empty - leave the field out to provision no plan, or name the ` +
|
|
71
74
|
'plan new customers should start on.');
|
|
72
75
|
}
|
|
73
76
|
return named;
|
|
@@ -178,7 +181,32 @@ function assertKnownContext(context, where) {
|
|
|
178
181
|
throw new Error(`${where} has no field \`${key}\` - this SDK would ignore it, and the call would be ` +
|
|
179
182
|
`governed by whatever remained instead. Valid fields: ${Object.keys(CONTEXT_KEYS).join(', ')}.`);
|
|
180
183
|
}
|
|
184
|
+
// The shapes a JavaScript caller - or a run's metadata - can get wrong without a compiler to say
|
|
185
|
+
// so: each would otherwise surface later as a bare TypeError, or as a call that looks unmetered.
|
|
186
|
+
for (const key of STRING_FIELDS) {
|
|
187
|
+
const value = context[key];
|
|
188
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
189
|
+
throw new Error(`${where}.${key} must be a string, not ${typeof value}.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (context.usageMapper !== undefined && typeof context.usageMapper !== 'function') {
|
|
193
|
+
throw new Error(`${where}.usageMapper must be a function, not ${typeof context.usageMapper}.`);
|
|
194
|
+
}
|
|
195
|
+
const { dimensions } = context;
|
|
196
|
+
if (dimensions !== undefined &&
|
|
197
|
+
(typeof dimensions !== 'object' || dimensions === null || Array.isArray(dimensions))) {
|
|
198
|
+
throw new Error(`${where}.dimensions must be an object of dimension values.`);
|
|
199
|
+
}
|
|
200
|
+
// Values are matched against entity ids, which are strings: a number would match nothing, and
|
|
201
|
+
// the call would look attributed while governing nothing. An absent one is left to mean absent.
|
|
202
|
+
for (const [key, value] of Object.entries(dimensions ?? {})) {
|
|
203
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
204
|
+
throw new Error(`${where}.dimensions.${key} must be a string, not ${typeof value}.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
181
207
|
}
|
|
208
|
+
/** The fields that are strings or absent. The customer and the plan say so in messages of their own. */
|
|
209
|
+
const STRING_FIELDS = ['agentId', 'attributionKey', 'idempotencyKey', 'correlationId'];
|
|
182
210
|
/**
|
|
183
211
|
* One call's override, applied over what `wrap` already resolved.
|
|
184
212
|
*
|
package/dist/core/session.d.ts
CHANGED
|
@@ -203,9 +203,9 @@ export declare class GovernanceSession {
|
|
|
203
203
|
private hook;
|
|
204
204
|
/**
|
|
205
205
|
* Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
|
|
206
|
-
* reached - false
|
|
207
|
-
* refusal is an answer: the SDK reports it once, and the
|
|
208
|
-
* says about a customer that does not exist.
|
|
206
|
+
* reached - false for a passing failure (unreachable, rate limited, timed out), which is reported
|
|
207
|
+
* here and retried after a short wait. A refusal is an answer: the SDK reports it once, and the
|
|
208
|
+
* call goes on to meet whatever the check says about a customer that does not exist.
|
|
209
209
|
*/
|
|
210
210
|
private provisionCustomer;
|
|
211
211
|
/**
|
package/dist/core/session.js
CHANGED
|
@@ -434,9 +434,9 @@ class GovernanceSession {
|
|
|
434
434
|
}
|
|
435
435
|
/**
|
|
436
436
|
* Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
|
|
437
|
-
* reached - false
|
|
438
|
-
* refusal is an answer: the SDK reports it once, and the
|
|
439
|
-
* says about a customer that does not exist.
|
|
437
|
+
* reached - false for a passing failure (unreachable, rate limited, timed out), which is reported
|
|
438
|
+
* here and retried after a short wait. A refusal is an answer: the SDK reports it once, and the
|
|
439
|
+
* call goes on to meet whatever the check says about a customer that does not exist.
|
|
440
440
|
*/
|
|
441
441
|
async provisionCustomer(context) {
|
|
442
442
|
if (!context.defaultPlanId || !context.customerId || !this.deps.provisionCustomer)
|
package/dist/engine/engine.d.ts
CHANGED
|
@@ -126,6 +126,8 @@ export type EntityPlacement = {
|
|
|
126
126
|
*/
|
|
127
127
|
usageLimitOnCreate?: number;
|
|
128
128
|
cadence?: string;
|
|
129
|
+
/** The entities the budget is scoped to - its own assignment beside the agent's whole one. */
|
|
130
|
+
scopeEntityIds?: string[];
|
|
129
131
|
};
|
|
130
132
|
export type EntityInput = {
|
|
131
133
|
id: string;
|
|
@@ -94,9 +94,10 @@ export declare class NodeSdkEngine implements GovernanceEngine {
|
|
|
94
94
|
* mutation, and writes the entitlements it answers with straight into the cache - so the check
|
|
95
95
|
* that follows reads the new plan locally instead of fetching it.
|
|
96
96
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
97
|
+
* Resolves with a final answer - created, duplicate, or a refusal that will be the same next
|
|
98
|
+
* time. Throws for a passing failure - unreachable, rate limited, timed out - so the customer is
|
|
99
|
+
* asked again later: remembering one as a refusal would deny that customer until the process
|
|
100
|
+
* restarts.
|
|
100
101
|
*/
|
|
101
102
|
provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
|
|
102
103
|
upsertEntity(): Promise<void>;
|
|
@@ -57,6 +57,20 @@ const report_1 = require("./report");
|
|
|
57
57
|
* connection, a dropped socket - which is what tells a refusal apart from an outage.
|
|
58
58
|
*/
|
|
59
59
|
const errorCode = (err) => err?.errorCode;
|
|
60
|
+
/**
|
|
61
|
+
* The failures that say nothing about the customer - a rate limit, a timeout, an unexpected error -
|
|
62
|
+
* so asking again later may succeed. Every other code Stigg answers with is a final answer about
|
|
63
|
+
* this customer or this configuration - an unknown plan, a key that cannot write, an invalid id -
|
|
64
|
+
* and asking again gets the same one.
|
|
65
|
+
*/
|
|
66
|
+
const PASSING_FAILURES = new Set([
|
|
67
|
+
'RateLimitExceeded',
|
|
68
|
+
'OperationTimeout',
|
|
69
|
+
'UnexpectedError',
|
|
70
|
+
'RequestTimeout',
|
|
71
|
+
// A lock another request holds on the customer - creating one takes it.
|
|
72
|
+
'ObjectAlreadyBeingUsedByAnotherRequestError',
|
|
73
|
+
]);
|
|
60
74
|
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
61
75
|
/**
|
|
62
76
|
* Drops keys that were never set, so a spread cannot un-set a default.
|
|
@@ -148,9 +162,10 @@ class NodeSdkEngine {
|
|
|
148
162
|
* mutation, and writes the entitlements it answers with straight into the cache - so the check
|
|
149
163
|
* that follows reads the new plan locally instead of fetching it.
|
|
150
164
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
165
|
+
* Resolves with a final answer - created, duplicate, or a refusal that will be the same next
|
|
166
|
+
* time. Throws for a passing failure - unreachable, rate limited, timed out - so the customer is
|
|
167
|
+
* asked again later: remembering one as a refusal would deny that customer until the process
|
|
168
|
+
* restarts.
|
|
154
169
|
*/
|
|
155
170
|
async provisionCustomer(customerId, planId) {
|
|
156
171
|
try {
|
|
@@ -165,7 +180,7 @@ class NodeSdkEngine {
|
|
|
165
180
|
const code = errorCode(err);
|
|
166
181
|
if (code === 'DuplicatedEntityNotAllowed')
|
|
167
182
|
return { status: 'existing' };
|
|
168
|
-
if (code && code
|
|
183
|
+
if (code && !PASSING_FAILURES.has(code)) {
|
|
169
184
|
return { status: 'rejected', code, reason: err instanceof Error ? err.message : String(err) };
|
|
170
185
|
}
|
|
171
186
|
throw (0, errors_1.asEngineError)('provisionCustomer', err);
|
|
@@ -87,6 +87,8 @@ class RestGovernanceEngine {
|
|
|
87
87
|
...capability,
|
|
88
88
|
// Tri-state: omitted leaves the parent alone, null makes it a root, an id re-homes it.
|
|
89
89
|
...(placement.parentId === undefined ? {} : { parentId: placement.parentId }),
|
|
90
|
+
// Part of the assignment's identity: a scoped budget is its own row beside the whole one.
|
|
91
|
+
...(placement.scopeEntityIds ? { scopeEntityIds: placement.scopeEntityIds } : {}),
|
|
90
92
|
...budget,
|
|
91
93
|
},
|
|
92
94
|
],
|
|
@@ -107,14 +109,18 @@ class RestGovernanceEngine {
|
|
|
107
109
|
return { usageLimit: placement.usageLimit, ...cadence };
|
|
108
110
|
if (placement.usageLimitOnCreate === undefined)
|
|
109
111
|
return cadence;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
112
|
+
// An assignment is the `(entity, capability, scope)` triple, so "exists" means one with this
|
|
113
|
+
// scope - the agent's whole budget existing says nothing about its per-model one.
|
|
114
|
+
// Encoded whole, not joined: an id may hold a comma, and ['a,b'] is not ['a', 'b'].
|
|
115
|
+
const scopeOf = (ids) => JSON.stringify([...(ids ?? [])].sort());
|
|
116
|
+
const scope = scopeOf(placement.scopeEntityIds);
|
|
117
|
+
// Every page: the one with this scope can sit past the first, and missing it would overwrite
|
|
118
|
+
// an admin's limit with the seed.
|
|
119
|
+
for await (const row of this.client.v1Beta.customers.assignments.list(customerId, { entityId, ...capability })) {
|
|
120
|
+
// Already assigned: leave the limit and its window exactly as they are.
|
|
121
|
+
if (scopeOf(row.scopeEntityIds) === scope)
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
118
124
|
return { usageLimit: placement.usageLimitOnCreate, ...cadence };
|
|
119
125
|
}
|
|
120
126
|
async check(customerId, { entitlement, requestedUsage, dimensions }) {
|
|
@@ -9,6 +9,6 @@
|
|
|
9
9
|
* import { StiggAgentSDK } from "@stigg/agent-sdk-js";
|
|
10
10
|
* import { StiggLangChainCallbackHandler, AccessDeniedError } from "@stigg/agent-sdk-js/langchain";
|
|
11
11
|
*/
|
|
12
|
-
export { StiggLangChainCallbackHandler, CUSTOMER_METADATA_KEY, } from '../bindings/orchestrator/langchain/callback-handler';
|
|
12
|
+
export { StiggLangChainCallbackHandler, CONTEXT_METADATA_KEY, CUSTOMER_METADATA_KEY, } from '../bindings/orchestrator/langchain/callback-handler';
|
|
13
13
|
export type { StiggLangChainCallbackOptions } from '../bindings/orchestrator/langchain/callback-handler';
|
|
14
14
|
export { AccessDeniedError, isAccessDenied } from '../errors';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isAccessDenied = exports.AccessDeniedError = exports.CUSTOMER_METADATA_KEY = exports.StiggLangChainCallbackHandler = void 0;
|
|
3
|
+
exports.isAccessDenied = exports.AccessDeniedError = exports.CUSTOMER_METADATA_KEY = exports.CONTEXT_METADATA_KEY = exports.StiggLangChainCallbackHandler = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* `@stigg/agent-sdk-js/langchain` - Stigg governance for LangChain and LangGraph.
|
|
6
6
|
*
|
|
@@ -14,6 +14,7 @@ exports.isAccessDenied = exports.AccessDeniedError = exports.CUSTOMER_METADATA_K
|
|
|
14
14
|
*/
|
|
15
15
|
var callback_handler_1 = require("../bindings/orchestrator/langchain/callback-handler");
|
|
16
16
|
Object.defineProperty(exports, "StiggLangChainCallbackHandler", { enumerable: true, get: function () { return callback_handler_1.StiggLangChainCallbackHandler; } });
|
|
17
|
+
Object.defineProperty(exports, "CONTEXT_METADATA_KEY", { enumerable: true, get: function () { return callback_handler_1.CONTEXT_METADATA_KEY; } });
|
|
17
18
|
Object.defineProperty(exports, "CUSTOMER_METADATA_KEY", { enumerable: true, get: function () { return callback_handler_1.CUSTOMER_METADATA_KEY; } });
|
|
18
19
|
// What a refusal is, re-exported so a catch block needs one import: `e instanceof AccessDeniedError`.
|
|
19
20
|
var errors_1 = require("../errors");
|
|
@@ -165,12 +165,18 @@ export declare class StiggAgentSDK {
|
|
|
165
165
|
*
|
|
166
166
|
* A refusal is remembered like a success: an unknown plan or a key that cannot write will be
|
|
167
167
|
* refused the same way next time, and retrying it would spend a rate-limited call on every call.
|
|
168
|
-
*
|
|
168
|
+
* A passing failure - unreachable, rate limited - is remembered for
|
|
169
|
+
* {@link PROVISION_RETRY_AFTER_MS}, then forgotten, so the customer is asked again: soon, but not
|
|
170
|
+
* on every call while Stigg is the one asking for less.
|
|
169
171
|
*
|
|
170
172
|
* Capped at {@link KNOWN_CUSTOMERS_CAP}, oldest first. Forgetting a customer costs one more
|
|
171
173
|
* provisioning call, which Stigg answers as a duplicate - so the cap bounds memory, not safety.
|
|
172
174
|
*/
|
|
173
175
|
private readonly customers;
|
|
176
|
+
/** When a customer whose provisioning failed in passing may be asked again. */
|
|
177
|
+
private readonly provisionRetryAt;
|
|
178
|
+
/** Not readonly, so a test can shorten the wait rather than sleep through it. */
|
|
179
|
+
private provisionRetryAfterMs;
|
|
174
180
|
private started?;
|
|
175
181
|
/**
|
|
176
182
|
* When the attempt currently in `started` rejected, or undefined while it is pending or has
|
package/dist/stigg-agent-sdk.js
CHANGED
|
@@ -126,6 +126,8 @@ const RETRY_START_AFTER_MS = 2_000;
|
|
|
126
126
|
* of tenants would otherwise grow this for as long as it runs.
|
|
127
127
|
*/
|
|
128
128
|
const KNOWN_CUSTOMERS_CAP = 10_000;
|
|
129
|
+
/** How long a customer whose provisioning failed in passing waits before it is asked again. */
|
|
130
|
+
const PROVISION_RETRY_AFTER_MS = 30_000;
|
|
129
131
|
/** The key shorthand and the options object, folded into the one object the config takes. */
|
|
130
132
|
function toOptions(first, second) {
|
|
131
133
|
return typeof first === 'string' ? { ...second, apiKey: first } : { ...first, ...second };
|
|
@@ -134,7 +136,7 @@ function toOptions(first, second) {
|
|
|
134
136
|
function toPlacement(input) {
|
|
135
137
|
if (!input.budget)
|
|
136
138
|
return undefined;
|
|
137
|
-
const { entitlement, cadence, parentId } = input.budget;
|
|
139
|
+
const { entitlement, cadence, parentId, scope } = input.budget;
|
|
138
140
|
// A placement writes an assignment against the entitlement, so an empty one persists a budget
|
|
139
141
|
// attached to nothing rather than merely routing badly.
|
|
140
142
|
const invalid = (0, types_1.entitlementError)(entitlement);
|
|
@@ -151,12 +153,19 @@ function toPlacement(input) {
|
|
|
151
153
|
"`budget.apply: 'on-create'` writes a cap the first time and never again, so there is " +
|
|
152
154
|
'nothing to write without `budget.limit`.');
|
|
153
155
|
}
|
|
156
|
+
// Each entry names an entity, and a blank one names none: the budget would be scoped to an
|
|
157
|
+
// entity no request resolves, and apply to nothing.
|
|
158
|
+
if (scope !== undefined && (!Array.isArray(scope) || scope.some((id) => typeof id !== 'string' || !id.trim()))) {
|
|
159
|
+
throw new Error(`Agent ${input.agentId} has a budget.scope that is not a list of entity ids: ${JSON.stringify(scope)}. ` +
|
|
160
|
+
'Leave it out, or pass [], for the whole budget.');
|
|
161
|
+
}
|
|
154
162
|
return {
|
|
155
163
|
parentId,
|
|
156
164
|
entitlement,
|
|
157
165
|
cadence,
|
|
158
166
|
usageLimit: onCreate ? undefined : input.budget.limit,
|
|
159
167
|
usageLimitOnCreate: onCreate ? (input.budget.limit ?? undefined) : undefined,
|
|
168
|
+
scopeEntityIds: scope?.length ? scope : undefined,
|
|
160
169
|
};
|
|
161
170
|
}
|
|
162
171
|
class StiggAgentSDK {
|
|
@@ -196,12 +205,18 @@ class StiggAgentSDK {
|
|
|
196
205
|
*
|
|
197
206
|
* A refusal is remembered like a success: an unknown plan or a key that cannot write will be
|
|
198
207
|
* refused the same way next time, and retrying it would spend a rate-limited call on every call.
|
|
199
|
-
*
|
|
208
|
+
* A passing failure - unreachable, rate limited - is remembered for
|
|
209
|
+
* {@link PROVISION_RETRY_AFTER_MS}, then forgotten, so the customer is asked again: soon, but not
|
|
210
|
+
* on every call while Stigg is the one asking for less.
|
|
200
211
|
*
|
|
201
212
|
* Capped at {@link KNOWN_CUSTOMERS_CAP}, oldest first. Forgetting a customer costs one more
|
|
202
213
|
* provisioning call, which Stigg answers as a duplicate - so the cap bounds memory, not safety.
|
|
203
214
|
*/
|
|
204
215
|
customers = new Map();
|
|
216
|
+
/** When a customer whose provisioning failed in passing may be asked again. */
|
|
217
|
+
provisionRetryAt = new Map();
|
|
218
|
+
/** Not readonly, so a test can shorten the wait rather than sleep through it. */
|
|
219
|
+
provisionRetryAfterMs = PROVISION_RETRY_AFTER_MS;
|
|
205
220
|
started;
|
|
206
221
|
/**
|
|
207
222
|
* When the attempt currently in `started` rejected, or undefined while it is pending or has
|
|
@@ -262,6 +277,8 @@ class StiggAgentSDK {
|
|
|
262
277
|
if (typeof agentId !== 'string' || !agentId.trim()) {
|
|
263
278
|
throw new Error('register needs an agentId - an empty one provisions an entity nobody can name.');
|
|
264
279
|
}
|
|
280
|
+
// Validated by presence, not truthiness: a blank plan is a typo, not a request for none.
|
|
281
|
+
const defaultPlanId = (0, context_1.planId)(input.defaultPlanId, 'register defaultPlanId');
|
|
265
282
|
const placement = toPlacement(input);
|
|
266
283
|
const key = `${customerId}:${agentId}`;
|
|
267
284
|
// Memoized on the description, not just the pair: a second call describing the agent
|
|
@@ -284,6 +301,10 @@ class StiggAgentSDK {
|
|
|
284
301
|
// built: `run()` already validates in this order, and a malformed call that reports itself as
|
|
285
302
|
// a probe timeout sends the reader looking at the network for a typo they can see.
|
|
286
303
|
await this.ensureStarted();
|
|
304
|
+
// The customer before the agent: an entity is created under a customer, so one Stigg has never
|
|
305
|
+
// seen would be refused. The same once-per-customer provisioning every check does.
|
|
306
|
+
if (defaultPlanId)
|
|
307
|
+
await this.provisionCustomer(customerId, defaultPlanId);
|
|
287
308
|
if (!this.engine.capabilities.provisioning) {
|
|
288
309
|
// Only a call that asked for something governance-only is an error. A budget or a parent
|
|
289
310
|
// cannot be honoured, and carrying on would leave an agent that looks governed with a
|
|
@@ -428,6 +449,10 @@ class StiggAgentSDK {
|
|
|
428
449
|
onUnlisted: (agent) => this.config.logger.warn(`[stigg] agent "${agent}" is not listed in the middleware's \`agents\`, so its calls are ` +
|
|
429
450
|
'governed by the shared `stiggContext` alone - at the customer level unless that context ' +
|
|
430
451
|
'names an agent. List it to govern it as itself.'),
|
|
452
|
+
onUncheckedTool: (tool) => this.config.logger.warn(`[stigg] tool "${tool}" was metered but not checked: it started with no name, so nothing ` +
|
|
453
|
+
"could match it until its result named it. Add `'*'` to `tools` to check tools like it."),
|
|
454
|
+
onUnnamedTool: (kind) => this.config.logger.warn(`[stigg] a tool${kind ? ` (${kind})` : ''} ran with no name, outside a model's tool call, so it ` +
|
|
455
|
+
"could not be checked or priced. If it is a paid tool, add `'*'` to `tools` to govern tools like it."),
|
|
431
456
|
missingCustomer: options.missingCustomer ??
|
|
432
457
|
'Name one per run, or give the middleware a default in `stiggContext: { customerId }`.',
|
|
433
458
|
});
|
|
@@ -514,8 +539,12 @@ class StiggAgentSDK {
|
|
|
514
539
|
*/
|
|
515
540
|
provisionCustomer(customerId, planId) {
|
|
516
541
|
const known = this.customers.get(customerId);
|
|
517
|
-
|
|
542
|
+
const retryAt = this.provisionRetryAt.get(customerId);
|
|
543
|
+
// A passing failure answers every call until its wait is over - asked again on each one, a
|
|
544
|
+
// rate-limited Stigg would only be asked harder.
|
|
545
|
+
if (known && (retryAt === undefined || Date.now() < retryAt))
|
|
518
546
|
return known;
|
|
547
|
+
this.provisionRetryAt.delete(customerId);
|
|
519
548
|
const pending = this.ensureStarted().then(async ({ engine }) => {
|
|
520
549
|
const outcome = await engine.provisionCustomer?.(customerId, planId);
|
|
521
550
|
if (outcome?.status === 'created')
|
|
@@ -530,13 +559,16 @@ class StiggAgentSDK {
|
|
|
530
559
|
});
|
|
531
560
|
this.customers.set(customerId, pending);
|
|
532
561
|
if (this.customers.size > KNOWN_CUSTOMERS_CAP) {
|
|
533
|
-
this.customers.
|
|
562
|
+
const oldest = this.customers.keys().next().value;
|
|
563
|
+
this.customers.delete(oldest);
|
|
564
|
+
this.provisionRetryAt.delete(oldest);
|
|
534
565
|
}
|
|
535
566
|
// Only its own entry: one evicted by the cap may have been replaced by a newer attempt for the
|
|
536
|
-
// same customer by the time it fails, and
|
|
567
|
+
// same customer by the time it fails, and timing that one out would re-ask a settled customer.
|
|
537
568
|
pending.catch(() => {
|
|
538
|
-
if (this.customers.get(customerId) === pending)
|
|
539
|
-
this.
|
|
569
|
+
if (this.customers.get(customerId) === pending) {
|
|
570
|
+
this.provisionRetryAt.set(customerId, Date.now() + this.provisionRetryAfterMs);
|
|
571
|
+
}
|
|
540
572
|
});
|
|
541
573
|
return pending;
|
|
542
574
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -359,8 +359,9 @@ export type ClientContext = Omit<StiggContext, 'idempotencyKey'> & {
|
|
|
359
359
|
* agent and nothing else.
|
|
360
360
|
*
|
|
361
361
|
* The customer is the one thing usually left out, because that changes per request: it rides each
|
|
362
|
-
* run instead,
|
|
363
|
-
*
|
|
362
|
+
* run instead, in the run's own `stiggContext` - the per-call override `wrap` takes - wherever the
|
|
363
|
+
* binding says (on LangChain, `metadata.stiggContext`), and wins over a default here. Set it
|
|
364
|
+
* server-side, from the authenticated user, never from the request.
|
|
364
365
|
*/
|
|
365
366
|
export type MiddlewareOptions = {
|
|
366
367
|
/**
|
|
@@ -498,6 +499,18 @@ export type AgentBudget = {
|
|
|
498
499
|
cadence?: string;
|
|
499
500
|
/** Places the agent under a parent entity. Tri-state: omit to leave it, null to make it a root. */
|
|
500
501
|
parentId?: string | null;
|
|
502
|
+
/**
|
|
503
|
+
* Narrows the budget to requests whose resolved entities include every one of these - a budget
|
|
504
|
+
* per value of a dimension, such as one per model: register each model as an entity of a type
|
|
505
|
+
* whose attribution key is the dimension, then scope one budget to each. Each scope is its own
|
|
506
|
+
* budget on the agent, so register once per scope. Omitted or empty, the budget is the agent's
|
|
507
|
+
* whole one.
|
|
508
|
+
*
|
|
509
|
+
* A scoped budget governs only the requests it names: one naming no model, or a model with no
|
|
510
|
+
* budget, is limited by the agent's whole budget and its ancestors' alone - with neither, by
|
|
511
|
+
* nothing. Register the whole budget too.
|
|
512
|
+
*/
|
|
513
|
+
scope?: string[];
|
|
501
514
|
} & ({
|
|
502
515
|
/** The limit, written on every call. `null` clears whatever is assigned. */
|
|
503
516
|
limit?: number | null;
|
|
@@ -519,7 +532,14 @@ export type AgentScope = {
|
|
|
519
532
|
customerId: string;
|
|
520
533
|
};
|
|
521
534
|
/** `register` - the standalone call, which has no run to take the scope from. */
|
|
522
|
-
export type RegisterInput = AgentOptions & AgentScope
|
|
535
|
+
export type RegisterInput = AgentOptions & AgentScope & {
|
|
536
|
+
/**
|
|
537
|
+
* The plan to provision the customer onto first, when Stigg does not know them yet - the same
|
|
538
|
+
* provisioning a check with `stiggContext.defaultPlanId` does, once per customer per process.
|
|
539
|
+
* Without it, registering under a customer Stigg has never seen fails: an entity needs one.
|
|
540
|
+
*/
|
|
541
|
+
defaultPlanId?: string;
|
|
542
|
+
};
|
|
523
543
|
/** `deregister` - archiving the entity. No entitlement: an entity is not archived per-entitlement. */
|
|
524
544
|
export type DeregisterInput = Pick<AgentScope, 'customerId'> & Pick<AgentOptions, 'agentId'>;
|
|
525
545
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigg/agent-sdk-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"example-client-typesafe": "tsx examples/client/typesafe.ts",
|
|
50
50
|
"example-two-agents": "tsx examples/client/two-agents.ts",
|
|
51
51
|
"example-orchestrator-langchain": "tsx examples/orchestrator/langchain.ts",
|
|
52
|
-
"example-orchestrator-langgraph": "tsx examples/orchestrator/langgraph.ts",
|
|
52
|
+
"example-orchestrator-langgraph-handoff": "tsx examples/orchestrator/langgraph-handoff.ts",
|
|
53
|
+
"example-orchestrator-langgraph-model-fallback": "tsx examples/orchestrator/langgraph-model-fallback.ts",
|
|
53
54
|
"example-gateway": "tsx examples/gateway/openrouter.ts",
|
|
54
55
|
"example-gateway-openrouter": "tsx examples/gateway/openrouter.ts",
|
|
55
56
|
"example-gateway-litellm": "tsx examples/gateway/litellm.ts",
|