@stigg/agent-sdk-js 0.5.0 → 0.5.1

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.
@@ -40,7 +40,7 @@ export type AgentMiddleware = {
40
40
  /** Before a tool runs: checked when the tool is a paid one, raising when refused. */
41
41
  startTool(runId: string, tool: ToolStep, attribution: StepAttribution): Promise<void>;
42
42
  /** After a tool succeeded: what its `usage` makes of the call, or one unit. Never throws. */
43
- endTool(runId: string, output?: unknown): Promise<void>;
43
+ endTool(runId: string, output?: unknown, name?: string): Promise<void>;
44
44
  /** A step that failed: forgotten, and billed nothing - it did not deliver what it charges for. */
45
45
  abandon(runId: string): void;
46
46
  };
@@ -75,6 +75,13 @@ export type AgentMiddlewareConfig = {
75
75
  agents: ReadonlyMap<string, ResolvedAgent>;
76
76
  /** Told once per agent that was not listed, so the caller can say so. */
77
77
  onUnlisted(agent: string): void;
78
+ /** Told once per tool whose usage was reported but not checked, because its name came only with its result. */
79
+ onUncheckedTool(tool: string): void;
80
+ /**
81
+ * Told once per kind of tool that ran with no name at all - called directly, so nothing named it
82
+ * and it could not be priced. Possibly a free tool, so a warning rather than a metering failure.
83
+ */
84
+ onUnnamedTool(kind: string | undefined): void;
78
85
  /** Where the caller puts the customer - quoted when a step has none. */
79
86
  missingCustomer: string;
80
87
  };
@@ -23,6 +23,7 @@ exports.createAgentMiddleware = createAgentMiddleware;
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 = '*';
@@ -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,6 +118,10 @@ 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
@@ -117,9 +129,14 @@ function createAgentMiddleware(session, config) {
117
129
  */
118
130
  const agentFor = (agent) => {
119
131
  const own = agent === undefined ? undefined : config.agents.get(agent);
120
- if (!own && agent !== undefined && config.agents.size > 0 && !warnedUnlisted.has(agent)) {
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
  };
@@ -141,10 +158,18 @@ function createAgentMiddleware(session, config) {
141
158
  /** A step in flight. One dropped to make room is never billed, so that is said, not silent. */
142
159
  const rememberStep = (runId, step) => {
143
160
  const dropped = remember(steps, runId, step, STEPS_LIMIT);
144
- if (dropped) {
145
- session.failedToMeasure(dropped.context, new Error(`a step was still in flight after ${STEPS_LIMIT} newer ones started, so it was dropped unbilled`));
146
- }
161
+ if (dropped)
162
+ reportDropped(dropped.context, 'a step');
147
163
  };
164
+ /** An unnamed tool held until its result names it - and said, if it is dropped to make room. */
165
+ const rememberUnnamed = (runId, tool) => {
166
+ const dropped = remember(unnamedTools, runId, tool, STEPS_LIMIT);
167
+ if (dropped)
168
+ reportDropped(dropped.context, 'a tool with no name');
169
+ };
170
+ 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`));
171
+ /** The application's warnings are its own code: one that throws must not fail a step. */
172
+ const warn = (say) => (0, hooks_1.safely)(say, () => undefined);
148
173
  /** The tool calls a reply asked for, kept until each tool has run. */
149
174
  const rememberIssuedCalls = (issued, agent) => {
150
175
  for (const call of issued)
@@ -156,8 +181,8 @@ function createAgentMiddleware(session, config) {
156
181
  const priced = (0, context_1.overrideContext)(agent.context, tool.entitlement ? { entitlement: tool.entitlement, eventName } : { eventName });
157
182
  return { ...contextFor(priced, attribution), tool: name };
158
183
  };
159
- /** Bills a tool step that ended: its `usage`, or one unit. A bad `usage` is a metering failure. */
160
- const billTool = async ({ context, idempotencyKey, tool }, output) => {
184
+ /** Reports what a tool step that ended used: its `usage`, or one unit. A bad `usage` is a metering failure. */
185
+ const ingestTool = async ({ context, idempotencyKey, tool }, output) => {
161
186
  const options = { idempotencyKey };
162
187
  if (!tool?.usage) {
163
188
  await session.ingest(context, 1, options);
@@ -174,6 +199,40 @@ function createAgentMiddleware(session, config) {
174
199
  session.failedToMeasure(context, err);
175
200
  }
176
201
  };
202
+ /**
203
+ * The step an unnamed tool would have started, now that its result names it - or nothing to
204
+ * report, when it has no name or the name is not governed. It ran unchecked, which is said once
205
+ * per tool: only `'*'` can gate a tool whose name is not known when it starts.
206
+ */
207
+ const stepFromResult = (runId, name) => {
208
+ const unnamed = unnamedTools.get(runId);
209
+ if (!unnamed)
210
+ return undefined;
211
+ if (name === undefined) {
212
+ // Called directly - no tool call, no ToolMessage - so nothing ever named it. It may well be a
213
+ // free tool, so this is said once per kind of tool rather than raised as a metering failure.
214
+ const kind = unnamed.kind ?? '(unknown)';
215
+ if (!warnedUnnamed.has(kind) && warnedUnnamed.size < 100) {
216
+ warnedUnnamed.add(kind);
217
+ warn(() => config.onUnnamedTool(unnamed.kind));
218
+ }
219
+ return undefined;
220
+ }
221
+ const agent = agentFor(unnamed.owner.agent);
222
+ const tool = toolFor(agent.tools, name);
223
+ if (!tool)
224
+ return undefined;
225
+ if (tool.entitlement && !warnedUnchecked.has(name)) {
226
+ warnedUnchecked.add(name);
227
+ warn(() => config.onUncheckedTool(name));
228
+ }
229
+ const context = toolContext(agent, tool, name, unnamed.owner);
230
+ return {
231
+ context,
232
+ tool: { name, input: unnamed.input, usage: tool.usage, callId: unnamed.callId },
233
+ idempotencyKey: toolKey(unnamed.callId, context, name),
234
+ };
235
+ };
177
236
  return {
178
237
  async startModel(runId, attribution, eventName) {
179
238
  const resolved = contextFor(agentFor(attribution.agent).context, attribution);
@@ -205,26 +264,35 @@ function createAgentMiddleware(session, config) {
205
264
  const owner = { ...attribution, agent: issued?.agent ?? attribution.agent };
206
265
  const agent = agentFor(owner.agent);
207
266
  const tool = toolName ? toolFor(agent.tools, named) : undefined;
208
- if (!toolName || !tool)
267
+ if (!toolName || !tool) {
268
+ // Nothing to check it against yet - but its result will name it, and a priced tool must
269
+ // not run free because its name was not known when it started.
270
+ // Only when a name could match - and with its context resolved now, so a run with no
271
+ // customer is refused before the tool runs, as a named tool is.
272
+ if (named === undefined && Object.values(agent.tools).some(Boolean)) {
273
+ rememberUnnamed(runId, { owner, context: contextFor(agent.context, owner), input, callId, kind });
274
+ }
209
275
  return;
276
+ }
210
277
  const context = toolContext(agent, tool, toolName, owner);
211
278
  if (tool.entitlement)
212
279
  await check(context);
213
280
  rememberStep(runId, {
214
281
  context,
215
282
  tool: { name: toolName, input, usage: tool.usage, callId },
216
- // One tool call is one report, and two entitlements on one call must not collide.
217
- idempotencyKey: callId ? `${callId}:${context.checkedId ?? toolName}` : undefined,
283
+ idempotencyKey: toolKey(callId, context, toolName),
218
284
  });
219
285
  },
220
- async endTool(runId, output) {
221
- const step = steps.get(runId);
222
- if (!step)
223
- return;
286
+ async endTool(runId, output, name) {
287
+ // Started governed, or started unnamed and governed by the name its result carries.
288
+ const step = steps.get(runId) ?? stepFromResult(runId, name);
224
289
  steps.delete(runId);
225
- await billTool(step, output);
290
+ unnamedTools.delete(runId);
291
+ if (step)
292
+ await ingestTool(step, output);
226
293
  },
227
294
  abandon(runId) {
295
+ unnamedTools.delete(runId);
228
296
  steps.delete(runId);
229
297
  },
230
298
  };
@@ -4,16 +4,22 @@
4
4
  * const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
5
5
  * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggCustomerId } });
6
6
  *
7
- * Everything here is LangChain's protocol - which hook a step starts on, where the agent's name,
8
- * the customer and the usage are. Whether a step may run and what it is worth is the middleware's,
9
- * and the handler keeps no state of its own. LangChain hands a callback to every run beneath the
10
- * `invoke`, sub-agents and LangGraph nodes included, so one handler governs the whole graph.
7
+ * Everything here is LangChain's protocol - which hook a step starts on, where the customer and
8
+ * 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 bill 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.
@@ -30,6 +36,12 @@ export declare const CUSTOMER_METADATA_KEY = "stiggCustomerId";
30
36
  export type StiggLangChainCallbackOptions = MiddlewareOptions & {
31
37
  /** The SDK this handler governs through - one per environment, shared with everything else. */
32
38
  stigg: StiggAgentSDK;
39
+ /**
40
+ * The agent the top-level run is - one of `agents`. Set it when the graph's entry point is itself
41
+ * an agent: the caller names the top-level run (`invoke(input, { runName })`), so without this its
42
+ * name would let them pick whose budget pays, or step outside any agent's.
43
+ */
44
+ rootAgent?: string;
33
45
  };
34
46
  export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
35
47
  name: string;
@@ -39,12 +51,22 @@ export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
39
51
  * given (`stigg.flush()`), and reported through the `hooks` it was configured with.
40
52
  */
41
53
  protected readonly middleware: GovernedAgentMiddleware;
54
+ private readonly runs;
42
55
  constructor(options: StiggLangChainCallbackOptions | StiggLangChainCallbackHandler);
43
- handleChatModelStart(_llm: unknown, _messages: unknown, runId: string, _parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
44
- handleLLMStart(_llm: unknown, _prompts: unknown, runId: string, _parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
56
+ handleChainStart(...args: Parameters<NonNullable<BaseCallbackHandler['handleChainStart']>>): void;
57
+ handleChainEnd(_outputs: unknown, runId: string): void;
58
+ handleChainError(_err: unknown, runId: string): void;
59
+ handleChatModelStart(_llm: unknown, _messages: unknown, runId: string, parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
60
+ handleLLMStart(_llm: unknown, _prompts: unknown, runId: string, parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
45
61
  handleLLMEnd(output: unknown, runId: string): Promise<void>;
46
62
  handleLLMError(_err: unknown, runId: string): void;
47
- handleToolStart(tool: unknown, input: string, runId: string, _parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): Promise<void>;
63
+ handleToolStart(tool: unknown, input: string, runId: string, parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): Promise<void>;
48
64
  handleToolEnd(output: unknown, runId: string): Promise<void>;
49
65
  handleToolError(_err: unknown, runId: string): void;
66
+ /**
67
+ * Who a step is for: the configured agent above it in the run tree, and the run's customer. With
68
+ * none above it, a name from metadata is passed on only as a label for the "not listed" warning -
69
+ * never a configured agent's name, which the tree alone can confer.
70
+ */
71
+ private attributionOf;
50
72
  }
@@ -7,21 +7,28 @@ exports.StiggLangChainCallbackHandler = exports.CUSTOMER_METADATA_KEY = void 0;
7
7
  * const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
8
8
  * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggCustomerId } });
9
9
  *
10
- * Everything here is LangChain's protocol - which hook a step starts on, where the agent's name,
11
- * the customer and the usage are. Whether a step may run and what it is worth is the middleware's,
12
- * and the handler keeps no state of its own. LangChain hands a callback to every run beneath the
13
- * `invoke`, sub-agents and LangGraph nodes included, so one handler governs the whole graph.
10
+ * Everything here is LangChain's protocol - which hook a step starts on, where the customer and
11
+ * 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 bill 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
34
  * The metadata key a caller names the customer under, on `invoke(input, { metadata })`. It wins
@@ -29,15 +36,76 @@ const token_usage_1 = require("../../shared/token-usage");
29
36
  * `metadata`, so set it in your auth handler, never from the request.
30
37
  */
31
38
  exports.CUSTOMER_METADATA_KEY = 'stiggCustomerId';
32
- /** What `createAgent({ name })` stamps on every run of that agent. */
39
+ /**
40
+ * What `createAgent({ name })` and LangGraph stamp in metadata. Never what a step is billed as -
41
+ * see the top of this file - only the label an unlisted agent is reported under.
42
+ */
33
43
  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
44
  const NODE_METADATA_KEY = 'langgraph_node';
45
+ /** How many runs can be open at once before the oldest is forgotten - a run whose end never came. */
46
+ const OPEN_RUNS_LIMIT = 10_000;
36
47
  /** The counters LangChain's `usage_metadata` states at the top level, reported under its own names. */
37
48
  const LANGCHAIN_METRICS = ['input_tokens', 'output_tokens', 'total_tokens'];
38
49
  /** Where a caller puts the customer, for the refusal a step with none raises. */
39
50
  const MISSING_CUSTOMER = `Name it on every run as \`invoke(input, { metadata: { ${exports.CUSTOMER_METADATA_KEY}: customerId } })\`, ` +
40
51
  'or give the handler a default in its `stiggContext: { customerId }`.';
52
+ /**
53
+ * The runs LangChain has open, each with its name and parent, so a step can be traced up to the
54
+ * agent it belongs to. Shared by a handler and every copy LangChain makes of it.
55
+ */
56
+ class RunTree {
57
+ agents;
58
+ rootAgent;
59
+ warn;
60
+ open = new Map();
61
+ warnedFull = false;
62
+ warned = new Set();
63
+ /**
64
+ * `rootAgent`, when set, is what a top-level run is, whatever it was named: the caller names that
65
+ * run (`invoke(input, { runName })`), so its name alone would let them pick whose budget pays.
66
+ * `warn` is the SDK's logger, for what is said once.
67
+ */
68
+ constructor(agents, rootAgent, warn) {
69
+ this.agents = agents;
70
+ this.rootAgent = rootAgent;
71
+ this.warn = warn;
72
+ }
73
+ start(runId, parent, name) {
74
+ this.open.set(runId, { name: parent === undefined && this.rootAgent ? this.rootAgent : name, parent });
75
+ if (this.open.size <= OPEN_RUNS_LIMIT)
76
+ return;
77
+ // A forgotten run breaks the walk for the steps beneath it: they fall back to the shared
78
+ // context - still checked and reported, but not as their agent - so that is said.
79
+ this.open.delete(this.open.keys().next().value);
80
+ if (!this.warnedFull) {
81
+ this.warnedFull = true;
82
+ this.warnOnce(`[stigg] more than ${OPEN_RUNS_LIMIT} LangChain runs are open at once, so the oldest are being ` +
83
+ 'forgotten: a step beneath one is governed by the shared context rather than as its agent.');
84
+ }
85
+ }
86
+ /** Says a thing once per process - and never fails a step for the logger's sake. */
87
+ warnOnce(message) {
88
+ if (this.warned.has(message) || this.warned.size >= 100)
89
+ return;
90
+ this.warned.add(message);
91
+ (0, hooks_1.safely)(() => this.warn(message), () => undefined);
92
+ }
93
+ end(runId) {
94
+ this.open.delete(runId);
95
+ }
96
+ /** The agent a run belongs to: itself when it is one, otherwise whatever its parent belongs to. */
97
+ agentAt(runId) {
98
+ const run = runId === undefined ? undefined : this.open.get(runId);
99
+ if (!run)
100
+ return undefined; // the top of the tree, or a run this handler never saw start
101
+ if (run.name !== undefined && this.isAgent(run.name))
102
+ return run.name;
103
+ return this.agentAt(run.parent);
104
+ }
105
+ isAgent(name) {
106
+ return this.agents.has(name);
107
+ }
108
+ }
41
109
  class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
42
110
  name = 'StiggLangChainCallbackHandler';
43
111
  /**
@@ -46,63 +114,101 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
46
114
  * given (`stigg.flush()`), and reported through the `hooks` it was configured with.
47
115
  */
48
116
  middleware;
117
+ runs;
49
118
  constructor(options) {
50
119
  super({ raiseError: true, _awaitHandler: true });
51
120
  if (options instanceof StiggLangChainCallbackHandler) {
52
- // LangChain's own copy, on its way into a nested run: the same graph, so the same middleware.
121
+ // LangChain's own copy, on its way into a nested run: the same graph, so the same state.
53
122
  this.middleware = options.middleware;
123
+ this.runs = options.runs;
54
124
  return;
55
125
  }
56
- const { stigg, ...middlewareOptions } = options;
126
+ const { stigg, rootAgent, ...middlewareOptions } = options;
127
+ if (rootAgent !== undefined && !options.agents?.[rootAgent]) {
128
+ throw new Error(`\`rootAgent\` names "${rootAgent}", which \`agents\` does not list - it must be one of them.`);
129
+ }
57
130
  if (!stigg || typeof stigg.middleware !== 'function') {
58
131
  throw new Error('`StiggLangChainCallbackHandler` needs `stigg`: the `StiggAgentSDK` it governs through.');
59
132
  }
60
133
  this.middleware = stigg.middleware({ ...middlewareOptions, missingCustomer: MISSING_CUSTOMER });
134
+ this.runs = new RunTree(new Set(Object.keys(options.agents ?? {})), rootAgent, (message) => stigg.config.logger.warn(message));
61
135
  }
62
- handleChatModelStart(_llm, _messages, runId, _parentRunId, _extraParams, _tags, metadata) {
63
- return this.middleware.startModel(runId, attributionOf(metadata), 'langchain.chat_model');
136
+ // The declared signature puts `runType` fourth and `parentRunId` last, but the callback manager
137
+ // calls this with `parentRunId` fourth and `runName` eighth - so it is read by what is passed.
138
+ // True of `@langchain/core` 1.2; the attribution tests run against the real manager, so a release
139
+ // that changes either side fails them.
140
+ handleChainStart(...args) {
141
+ const [, , runId, parentRunId, , , , runName] = args;
142
+ this.runs.start(runId, parentRunId, runName);
143
+ }
144
+ handleChainEnd(_outputs, runId) {
145
+ this.runs.end(runId);
146
+ }
147
+ handleChainError(_err, runId) {
148
+ this.runs.end(runId);
149
+ }
150
+ handleChatModelStart(_llm, _messages, runId, parentRunId, _extraParams, _tags, metadata) {
151
+ return this.middleware.startModel(runId, this.attributionOf(parentRunId, metadata), 'langchain.chat_model');
64
152
  }
65
153
  // The completion-model path, for an LLM that is not a chat model. Same gate, same meter.
66
- handleLLMStart(_llm, _prompts, runId, _parentRunId, _extraParams, _tags, metadata) {
67
- return this.middleware.startModel(runId, attributionOf(metadata), 'langchain.llm');
154
+ handleLLMStart(_llm, _prompts, runId, parentRunId, _extraParams, _tags, metadata) {
155
+ return this.middleware.startModel(runId, this.attributionOf(parentRunId, metadata), 'langchain.llm');
68
156
  }
69
157
  handleLLMEnd(output, runId) {
70
- const message = output?.generations?.[0]?.[0]?.message;
158
+ const result = output;
159
+ const message = result?.generations?.[0]?.[0]?.message;
71
160
  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);
161
+ return this.middleware.endModel(runId, measurementOf(message) ?? completionMeasurement(result, runId), issued);
73
162
  }
74
163
  handleLLMError(_err, runId) {
75
164
  this.middleware.abandon(runId);
76
165
  }
77
- handleToolStart(tool, input, runId, _parentRunId, _tags, metadata, runName, toolCallId) {
166
+ handleToolStart(tool, input, runId, parentRunId, _tags, metadata, runName, toolCallId) {
78
167
  // `runName` is set only for a tool built with `tool()`; a class-based one - `TavilySearch`,
79
168
  // most packaged integrations - starts with none, and a serialized form that names only its
80
169
  // class. The middleware names it from the model's own tool call, or by that class when no
81
170
  // model called it.
82
171
  const serialized = tool;
83
- return this.middleware.startTool(runId, { name: runName ?? serialized?.name, callId: toolCallId, kind: serialized?.id?.at(-1), input: decoded(input) }, attributionOf(metadata));
172
+ const name = runName ?? serialized?.name;
173
+ // A tool is a run too: a sub-agent it starts belongs below it in the tree.
174
+ this.runs.start(runId, parentRunId, name);
175
+ return this.middleware.startTool(runId, { name, callId: toolCallId, kind: serialized?.id?.at(-1), input: decoded(input) }, this.attributionOf(parentRunId, metadata));
84
176
  }
85
177
  handleToolEnd(output, runId) {
86
- // A tool the model called returns a `ToolMessage`, its result serialized into `content`.
87
- const content = isToolMessage(output) ? output.content : output;
88
- return this.middleware.endTool(runId, decoded(content));
178
+ this.runs.end(runId);
179
+ // A tool the model called returns a `ToolMessage`, its result serialized into `content` - and
180
+ // its name, for a tool that started without one.
181
+ const message = isToolMessage(output) ? output : undefined;
182
+ return this.middleware.endTool(runId, decoded(message ? message.content : output), message?.name);
89
183
  }
90
184
  handleToolError(_err, runId) {
185
+ this.runs.end(runId);
91
186
  this.middleware.abandon(runId);
92
187
  }
188
+ /**
189
+ * Who a step is for: the configured agent above it in the run tree, and the run's customer. With
190
+ * none above it, a name from metadata is passed on only as a label for the "not listed" warning -
191
+ * never a configured agent's name, which the tree alone can confer.
192
+ */
193
+ attributionOf(parentRunId, metadata) {
194
+ const label = metadata?.[AGENT_METADATA_KEY] ?? metadata?.[NODE_METADATA_KEY];
195
+ const unlisted = typeof label === 'string' && !this.runs.isAgent(label) ? label : undefined;
196
+ const customerId = metadata?.[exports.CUSTOMER_METADATA_KEY];
197
+ const agent = this.runs.agentAt(parentRunId);
198
+ // Metadata naming a configured agent the tree does not find is a misconfiguration - a subgraph
199
+ // node named apart from its agent, a top-level agent with no `rootAgent` - or a caller's attempt.
200
+ if (agent === undefined && typeof label === 'string' && this.runs.isAgent(label)) {
201
+ this.runs.warnOnce(`[stigg] a step's metadata names agent "${label}", but no run above it is named "${label}" - it is ` +
202
+ 'governed by the shared context. Name the node or the agent after it, or set `rootAgent` if it is ' +
203
+ 'the top-level agent.');
204
+ }
205
+ return {
206
+ agent: agent ?? unlisted,
207
+ customerId: typeof customerId === 'string' && customerId ? customerId : undefined,
208
+ };
209
+ }
93
210
  }
94
211
  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
212
  /** LangChain's `ToolMessage`, told by shape - the class is the application's copy, not ours. */
107
213
  function isToolMessage(value) {
108
214
  return (typeof value === 'object' &&
@@ -139,6 +245,26 @@ function measurementOf(message) {
139
245
  ...(message.id ? { idempotencyKey: message.id } : {}),
140
246
  };
141
247
  }
248
+ /**
249
+ * A completion model's usage, which LangChain reports on `llmOutput.tokenUsage` rather than on a
250
+ * message - in camelCase, so it is mapped onto the same names a chat model reports under.
251
+ */
252
+ function completionMeasurement(result, runId) {
253
+ const usage = result?.llmOutput?.tokenUsage;
254
+ if (!usage)
255
+ return undefined;
256
+ const raw = {
257
+ input_tokens: usage.promptTokens,
258
+ output_tokens: usage.completionTokens,
259
+ total_tokens: usage.totalTokens,
260
+ };
261
+ // An empty `tokenUsage` - a streamed completion's - says nothing, and is no measurement: reported
262
+ // as zero it would look like a call that cost nothing, rather than one nobody measured.
263
+ if (!Object.values(raw).some((value) => typeof value === 'number'))
264
+ return undefined;
265
+ // No provider message id to key on, so the run's own: the same run reported twice is one report.
266
+ return { metrics: pickNumbers(raw, LANGCHAIN_METRICS), usage: (0, token_usage_1.normalizeUsage)(raw), raw, idempotencyKey: runId };
267
+ }
142
268
  /** The named counters that are numbers, as they were stated. */
143
269
  function pickNumbers(source, names) {
144
270
  const metrics = {};
@@ -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 only for an outage, which is reported here and retried by the next call. A
207
- * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
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
  /**
@@ -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 only for an outage, which is reported here and retried by the next call. A
438
- * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
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)
@@ -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
- * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
98
- * because the caller treats them differently: a refusal will be the same next time, and an
99
- * unreachable server may not.
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
- * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
152
- * because the caller treats them differently: a refusal will be the same next time, and an
153
- * unreachable server may not.
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 !== 'RequestTimeout') {
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);
@@ -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
- * Only an unreachable Stigg is forgotten, so the customer's next call tries again.
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
@@ -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 };
@@ -196,12 +198,18 @@ class StiggAgentSDK {
196
198
  *
197
199
  * A refusal is remembered like a success: an unknown plan or a key that cannot write will be
198
200
  * refused the same way next time, and retrying it would spend a rate-limited call on every call.
199
- * Only an unreachable Stigg is forgotten, so the customer's next call tries again.
201
+ * A passing failure - unreachable, rate limited - is remembered for
202
+ * {@link PROVISION_RETRY_AFTER_MS}, then forgotten, so the customer is asked again: soon, but not
203
+ * on every call while Stigg is the one asking for less.
200
204
  *
201
205
  * Capped at {@link KNOWN_CUSTOMERS_CAP}, oldest first. Forgetting a customer costs one more
202
206
  * provisioning call, which Stigg answers as a duplicate - so the cap bounds memory, not safety.
203
207
  */
204
208
  customers = new Map();
209
+ /** When a customer whose provisioning failed in passing may be asked again. */
210
+ provisionRetryAt = new Map();
211
+ /** Not readonly, so a test can shorten the wait rather than sleep through it. */
212
+ provisionRetryAfterMs = PROVISION_RETRY_AFTER_MS;
205
213
  started;
206
214
  /**
207
215
  * When the attempt currently in `started` rejected, or undefined while it is pending or has
@@ -428,6 +436,10 @@ class StiggAgentSDK {
428
436
  onUnlisted: (agent) => this.config.logger.warn(`[stigg] agent "${agent}" is not listed in the middleware's \`agents\`, so its calls are ` +
429
437
  'governed by the shared `stiggContext` alone - at the customer level unless that context ' +
430
438
  'names an agent. List it to govern it as itself.'),
439
+ onUncheckedTool: (tool) => this.config.logger.warn(`[stigg] tool "${tool}" was metered but not checked: it started with no name, so nothing ` +
440
+ "could match it until its result named it. Add `'*'` to `tools` to check tools like it."),
441
+ onUnnamedTool: (kind) => this.config.logger.warn(`[stigg] a tool${kind ? ` (${kind})` : ''} ran with no name, outside a model's tool call, so it ` +
442
+ "could not be checked or priced. If it is a paid tool, add `'*'` to `tools` to govern tools like it."),
431
443
  missingCustomer: options.missingCustomer ??
432
444
  'Name one per run, or give the middleware a default in `stiggContext: { customerId }`.',
433
445
  });
@@ -514,8 +526,12 @@ class StiggAgentSDK {
514
526
  */
515
527
  provisionCustomer(customerId, planId) {
516
528
  const known = this.customers.get(customerId);
517
- if (known)
529
+ const retryAt = this.provisionRetryAt.get(customerId);
530
+ // A passing failure answers every call until its wait is over - asked again on each one, a
531
+ // rate-limited Stigg would only be asked harder.
532
+ if (known && (retryAt === undefined || Date.now() < retryAt))
518
533
  return known;
534
+ this.provisionRetryAt.delete(customerId);
519
535
  const pending = this.ensureStarted().then(async ({ engine }) => {
520
536
  const outcome = await engine.provisionCustomer?.(customerId, planId);
521
537
  if (outcome?.status === 'created')
@@ -530,13 +546,16 @@ class StiggAgentSDK {
530
546
  });
531
547
  this.customers.set(customerId, pending);
532
548
  if (this.customers.size > KNOWN_CUSTOMERS_CAP) {
533
- this.customers.delete(this.customers.keys().next().value);
549
+ const oldest = this.customers.keys().next().value;
550
+ this.customers.delete(oldest);
551
+ this.provisionRetryAt.delete(oldest);
534
552
  }
535
553
  // 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 deleting that one would re-ask a settled customer.
554
+ // same customer by the time it fails, and timing that one out would re-ask a settled customer.
537
555
  pending.catch(() => {
538
- if (this.customers.get(customerId) === pending)
539
- this.customers.delete(customerId);
556
+ if (this.customers.get(customerId) === pending) {
557
+ this.provisionRetryAt.set(customerId, Date.now() + this.provisionRetryAfterMs);
558
+ }
540
559
  });
541
560
  return pending;
542
561
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigg/agent-sdk-js",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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",