@stigg/agent-sdk-js 0.6.0 → 0.7.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
CHANGED
|
@@ -170,6 +170,7 @@ rather than handing back a client that quietly meters nothing.
|
|
|
170
170
|
| Start new customers on a plan | `stiggContext: { defaultPlanId }`, beside `customerId` |
|
|
171
171
|
| Meter a gateway you operate | `client.stigg.headers()` out, `stigg.ingestFromHeaders(...)` back |
|
|
172
172
|
| Govern a LangChain / LangGraph agent graph ([guide](docs/langchain.md)) | `new StiggLangChainCallbackHandler({ stigg, agents, tools })` from `@stigg/agent-sdk-js/langchain` |
|
|
173
|
+
| Govern a Google ADK agent graph ([guide](docs/guide.md#google-adk-one-plugin-per-runner)) | `new StiggAdkPlugin({ stigg, agents, tools, stepContext })` from `@stigg/agent-sdk-js/adk`, in the Runner's `plugins` |
|
|
173
174
|
| Govern a graph from your own harness | `stigg.middleware({ stiggContext, agents })` |
|
|
174
175
|
|
|
175
176
|
One wrapped client serves every customer and every agent: the context is an argument, given once at
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Google ADK binding: a Runner plugin that governs every agent the Runner runs.
|
|
3
|
+
*
|
|
4
|
+
* const stiggPlugin = new StiggAdkPlugin({ stigg, stiggContext, agents, stepContext });
|
|
5
|
+
* const runner = new InMemoryRunner({ agent, appName, plugins: [stiggPlugin] });
|
|
6
|
+
*
|
|
7
|
+
* How it works:
|
|
8
|
+
*
|
|
9
|
+
* - Before each model call and each tool call, the plugin asks Stigg whether the agent may run it.
|
|
10
|
+
* The agent is `callbackContext.agentName` - set by the agent tree the app built, so a request
|
|
11
|
+
* cannot change it. The customer comes from `stepContext`, because ADK has no per-run metadata.
|
|
12
|
+
* - After a model call, the plugin reports its token usage as that agent. After a tool call, it
|
|
13
|
+
* reports the tool. Failed calls report nothing.
|
|
14
|
+
* - ADK does not let a plugin throw a refusal (it turns the throw into an `UNKNOWN_ERROR` event), so
|
|
15
|
+
* a refusal is returned as the step's answer instead:
|
|
16
|
+
* - a refused model call returns `onRefusal`'s answer - `{ transferTo }` hands the turn to
|
|
17
|
+
* another agent - or else an error response with `errorCode: 'STIGG_ACCESS_DENIED'`. The
|
|
18
|
+
* model is never called;
|
|
19
|
+
* - a refused tool returns a short error as its result. The tool never runs, and the model
|
|
20
|
+
* carries on without it.
|
|
21
|
+
* `plugin.denialOf(event)` returns the decision behind either.
|
|
22
|
+
*
|
|
23
|
+
* Put this plugin first in `plugins`: ADK stops at the first plugin that returns a value, so a
|
|
24
|
+
* plugin before it that answers a step hides that step from Stigg.
|
|
25
|
+
*
|
|
26
|
+
* The one file in `src/` that imports ADK, reached only through the `@stigg/agent-sdk-js/adk` entry.
|
|
27
|
+
*/
|
|
28
|
+
import { BasePlugin, type BaseAgent, type BaseTool, type Context, type InvocationContext, type LlmRequest, type LlmResponse } from '@google/adk';
|
|
29
|
+
import type { StiggAgentSDK } from '../../../stigg-agent-sdk';
|
|
30
|
+
import type { AccessDecision, ClientContext, GovernedAgentMiddleware, MiddlewareOptions } from '../../../types';
|
|
31
|
+
/** The `errorCode` of a model call Stigg refused, on its `LlmResponse` and the event ADK makes of it. */
|
|
32
|
+
export declare const DENIED_ERROR_CODE = "STIGG_ACCESS_DENIED";
|
|
33
|
+
/** One step the plugin is about to check, for `stepContext` to resolve its `stiggContext` from. */
|
|
34
|
+
export type AdkStep = {
|
|
35
|
+
/** The agent ADK runs the step under. */
|
|
36
|
+
agent: string;
|
|
37
|
+
/** ADK's context for the step: the invocation's `userId`, `sessionId`, `invocationId`, and state. */
|
|
38
|
+
adkContext: Context;
|
|
39
|
+
/** The model about to be called, on a model step - for a budget per model, as a dimension. */
|
|
40
|
+
model?: string;
|
|
41
|
+
/** The tool about to run, on a tool step. */
|
|
42
|
+
tool?: string;
|
|
43
|
+
};
|
|
44
|
+
/** A model call Stigg refused, for `onRefusal` to decide what the agent answers instead. */
|
|
45
|
+
export type AdkRefusal = AdkStep & {
|
|
46
|
+
decision: AccessDecision;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* What a refused model call answers with: `{ transferTo }` to hand the turn to another agent the
|
|
50
|
+
* refused one can transfer to, any `LlmResponse` of your own, or nothing for the error response.
|
|
51
|
+
*/
|
|
52
|
+
export type AdkRefusalAnswer = {
|
|
53
|
+
transferTo: string;
|
|
54
|
+
} | LlmResponse | undefined;
|
|
55
|
+
export type StiggAdkPluginOptions = MiddlewareOptions & {
|
|
56
|
+
/** The SDK this plugin governs through - one per environment, shared with everything else. */
|
|
57
|
+
stigg: StiggAgentSDK;
|
|
58
|
+
/** The plugin's name in ADK's plugin manager, which refuses two of the same name. */
|
|
59
|
+
name?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The `stiggContext` a step states - typically the customer, from the invocation's user. Applied
|
|
62
|
+
* over the plugin's and the agent's, the way a LangChain run's `metadata.stiggContext` is. A
|
|
63
|
+
* tool's price from `tools` still applies over it.
|
|
64
|
+
*
|
|
65
|
+
* Resolve it from what your server knows, never from what a client sends: ADK's own API server
|
|
66
|
+
* lets the client choose `userId` and send a `stateDelta` with every run. `adkContext.userId` is
|
|
67
|
+
* only safe when your server sets it from the login. No `idempotencyKey`: every report beneath
|
|
68
|
+
* the step would share it.
|
|
69
|
+
*/
|
|
70
|
+
stepContext?: (step: AdkStep) => ClientContext | undefined | Promise<ClientContext | undefined>;
|
|
71
|
+
/**
|
|
72
|
+
* What a refused model call answers with instead - the graph's decision, as a LangGraph edge is.
|
|
73
|
+
* Return `{ transferTo: 'generalist' }` to hand the turn to an agent the refused one can transfer
|
|
74
|
+
* to, so the turn goes on without it. An agent hands off at most once per run, so two agents
|
|
75
|
+
* refused in turn cannot pass the turn back and forth forever; the second refusal ends in the error
|
|
76
|
+
* response. Left out, or returning nothing, the refused agent's turn ends in an error event.
|
|
77
|
+
*/
|
|
78
|
+
onRefusal?: (refusal: AdkRefusal) => AdkRefusalAnswer | Promise<AdkRefusalAnswer>;
|
|
79
|
+
};
|
|
80
|
+
export declare class StiggAdkPlugin extends BasePlugin {
|
|
81
|
+
/**
|
|
82
|
+
* The graph's middleware, which every callback below drives. Protected, for a subclass that
|
|
83
|
+
* traces: what the plugin did is flushed through the SDK it was given (`stigg.flush()`), and
|
|
84
|
+
* reported through the `hooks` it was configured with.
|
|
85
|
+
*/
|
|
86
|
+
protected readonly middleware: GovernedAgentMiddleware;
|
|
87
|
+
private readonly stepContext;
|
|
88
|
+
private readonly onRefusal;
|
|
89
|
+
private readonly warn;
|
|
90
|
+
private readonly warned;
|
|
91
|
+
/** Model calls by their `EventActions` - the one object ADK passes to every callback of a call. */
|
|
92
|
+
private readonly callsByActions;
|
|
93
|
+
/** Model calls whose usage is not reported yet, oldest first. */
|
|
94
|
+
private readonly openCalls;
|
|
95
|
+
/**
|
|
96
|
+
* Usage reports still in flight, by invocation. Each check waits for them first, so it sees the
|
|
97
|
+
* spend - including spend reported by another branch of a parallel agent.
|
|
98
|
+
*/
|
|
99
|
+
private readonly reporting;
|
|
100
|
+
/** Tool steps by their `Context`, which ADK builds once per tool call. */
|
|
101
|
+
private readonly toolRuns;
|
|
102
|
+
/**
|
|
103
|
+
* The decision behind each refused tool, by invocation and function call id - kept here, away from
|
|
104
|
+
* the model. Scoped like the tool's run id, since a model may reuse call ids across turns.
|
|
105
|
+
*/
|
|
106
|
+
private readonly toolDenials;
|
|
107
|
+
/** The agents that already handed off a refused turn, by invocation. */
|
|
108
|
+
private readonly handedOff;
|
|
109
|
+
private seq;
|
|
110
|
+
constructor(options: StiggAdkPluginOptions);
|
|
111
|
+
beforeRunCallback({ invocationContext }: {
|
|
112
|
+
invocationContext: InvocationContext;
|
|
113
|
+
}): Promise<undefined>;
|
|
114
|
+
beforeModelCallback({ callbackContext, llmRequest, }: {
|
|
115
|
+
callbackContext: Context;
|
|
116
|
+
llmRequest: LlmRequest;
|
|
117
|
+
}): Promise<LlmResponse | undefined>;
|
|
118
|
+
afterModelCallback({ callbackContext, llmResponse, }: {
|
|
119
|
+
callbackContext: Context;
|
|
120
|
+
llmResponse: LlmResponse;
|
|
121
|
+
}): Promise<LlmResponse | undefined>;
|
|
122
|
+
onModelErrorCallback({ callbackContext, }: {
|
|
123
|
+
callbackContext: Context;
|
|
124
|
+
llmRequest: LlmRequest;
|
|
125
|
+
error: Error;
|
|
126
|
+
}): Promise<LlmResponse | undefined>;
|
|
127
|
+
beforeToolCallback({ tool, toolArgs, toolContext, }: {
|
|
128
|
+
tool: BaseTool;
|
|
129
|
+
toolArgs: Record<string, unknown>;
|
|
130
|
+
toolContext: Context;
|
|
131
|
+
}): Promise<Record<string, unknown> | undefined>;
|
|
132
|
+
afterToolCallback({ tool, toolContext, result, }: {
|
|
133
|
+
tool: BaseTool;
|
|
134
|
+
toolArgs: Record<string, unknown>;
|
|
135
|
+
toolContext: Context;
|
|
136
|
+
result: Record<string, unknown>;
|
|
137
|
+
}): Promise<Record<string, unknown> | undefined>;
|
|
138
|
+
onToolErrorCallback({ toolContext, }: {
|
|
139
|
+
tool: BaseTool;
|
|
140
|
+
toolArgs: Record<string, unknown>;
|
|
141
|
+
toolContext: Context;
|
|
142
|
+
error: Error;
|
|
143
|
+
}): Promise<Record<string, unknown> | undefined>;
|
|
144
|
+
afterAgentCallback({ callbackContext, }: {
|
|
145
|
+
agent: BaseAgent;
|
|
146
|
+
callbackContext: Context;
|
|
147
|
+
}): Promise<undefined>;
|
|
148
|
+
afterRunCallback({ invocationContext }: {
|
|
149
|
+
invocationContext: InvocationContext;
|
|
150
|
+
}): Promise<void>;
|
|
151
|
+
/**
|
|
152
|
+
* The decision behind an event a refusal ended in - a refused model call's event (the error, or
|
|
153
|
+
* the answer `onRefusal` chose), or the function response of a refused tool - and `undefined` for
|
|
154
|
+
* any other event.
|
|
155
|
+
*/
|
|
156
|
+
denialOf(event: {
|
|
157
|
+
invocationId?: string;
|
|
158
|
+
customMetadata?: Record<string, unknown>;
|
|
159
|
+
content?: {
|
|
160
|
+
parts?: {
|
|
161
|
+
functionResponse?: {
|
|
162
|
+
id?: string;
|
|
163
|
+
};
|
|
164
|
+
}[];
|
|
165
|
+
};
|
|
166
|
+
}): AccessDecision | undefined;
|
|
167
|
+
/**
|
|
168
|
+
* What a refused model call answers: `onRefusal`'s choice, or the error response - with the
|
|
169
|
+
* decision in `customMetadata` either way, for `denialOf`. A transfer is a `transfer_to_agent`
|
|
170
|
+
* call, which ADK runs as if the model had made it.
|
|
171
|
+
*/
|
|
172
|
+
private answerRefusal;
|
|
173
|
+
/** Who a step is for: the agent ADK runs it under, and the `stiggContext` `stepContext` resolves. */
|
|
174
|
+
private attributionOf;
|
|
175
|
+
/**
|
|
176
|
+
* Opens a model call. When too many are open - runs that were aborted mid-stream - the oldest is
|
|
177
|
+
* reported with the usage it has, rather than dropped.
|
|
178
|
+
*/
|
|
179
|
+
private open;
|
|
180
|
+
/**
|
|
181
|
+
* Reports the invocation's open calls that `match`, then waits for every report of the invocation
|
|
182
|
+
* still in flight - its own, and ones another branch started.
|
|
183
|
+
*/
|
|
184
|
+
private settle;
|
|
185
|
+
/** Reports each call once, and waits for the invocation's reports in flight. */
|
|
186
|
+
private report;
|
|
187
|
+
/** Keeps a usage report in flight until it lands, so the invocation's next check waits for it. */
|
|
188
|
+
private track;
|
|
189
|
+
/** Says a thing once per process - and never fails a step for the logger's sake. */
|
|
190
|
+
private warnOnce;
|
|
191
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StiggAdkPlugin = exports.DENIED_ERROR_CODE = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The Google ADK binding: a Runner plugin that governs every agent the Runner runs.
|
|
6
|
+
*
|
|
7
|
+
* const stiggPlugin = new StiggAdkPlugin({ stigg, stiggContext, agents, stepContext });
|
|
8
|
+
* const runner = new InMemoryRunner({ agent, appName, plugins: [stiggPlugin] });
|
|
9
|
+
*
|
|
10
|
+
* How it works:
|
|
11
|
+
*
|
|
12
|
+
* - Before each model call and each tool call, the plugin asks Stigg whether the agent may run it.
|
|
13
|
+
* The agent is `callbackContext.agentName` - set by the agent tree the app built, so a request
|
|
14
|
+
* cannot change it. The customer comes from `stepContext`, because ADK has no per-run metadata.
|
|
15
|
+
* - After a model call, the plugin reports its token usage as that agent. After a tool call, it
|
|
16
|
+
* reports the tool. Failed calls report nothing.
|
|
17
|
+
* - ADK does not let a plugin throw a refusal (it turns the throw into an `UNKNOWN_ERROR` event), so
|
|
18
|
+
* a refusal is returned as the step's answer instead:
|
|
19
|
+
* - a refused model call returns `onRefusal`'s answer - `{ transferTo }` hands the turn to
|
|
20
|
+
* another agent - or else an error response with `errorCode: 'STIGG_ACCESS_DENIED'`. The
|
|
21
|
+
* model is never called;
|
|
22
|
+
* - a refused tool returns a short error as its result. The tool never runs, and the model
|
|
23
|
+
* carries on without it.
|
|
24
|
+
* `plugin.denialOf(event)` returns the decision behind either.
|
|
25
|
+
*
|
|
26
|
+
* Put this plugin first in `plugins`: ADK stops at the first plugin that returns a value, so a
|
|
27
|
+
* plugin before it that answers a step hides that step from Stigg.
|
|
28
|
+
*
|
|
29
|
+
* The one file in `src/` that imports ADK, reached only through the `@stigg/agent-sdk-js/adk` entry.
|
|
30
|
+
*/
|
|
31
|
+
const adk_1 = require("@google/adk");
|
|
32
|
+
const hooks_1 = require("../../../core/hooks");
|
|
33
|
+
const errors_1 = require("../../../errors");
|
|
34
|
+
const token_usage_1 = require("../../shared/token-usage");
|
|
35
|
+
/** How many model calls or refused tools are remembered at once. Past it, the oldest is dropped. */
|
|
36
|
+
const MEMORY_LIMIT = 10_000;
|
|
37
|
+
const TRANSFER_TOOL = 'transfer_to_agent';
|
|
38
|
+
/**
|
|
39
|
+
* ADK's own control tools. They are never checked, not even by `tools: { '*': ... }`: they do no
|
|
40
|
+
* work, and refusing one would break the agent - a refused `exit_loop` keeps a loop running.
|
|
41
|
+
*/
|
|
42
|
+
const CONTROL_TOOLS = new Set([TRANSFER_TOOL, 'exit_loop', 'set_model_response', 'finish_task']);
|
|
43
|
+
/** The `errorCode` of a model call Stigg refused, on its `LlmResponse` and the event ADK makes of it. */
|
|
44
|
+
exports.DENIED_ERROR_CODE = 'STIGG_ACCESS_DENIED';
|
|
45
|
+
/** Where a refused model call's decision is kept: its response's `customMetadata`, never seen by the model. */
|
|
46
|
+
const DECISION_KEY = 'stiggDecision';
|
|
47
|
+
/** Where a caller puts the customer, for the refusal a step with none raises. */
|
|
48
|
+
const MISSING_CUSTOMER = 'Resolve it per step with `stepContext: ({ adkContext }) => ({ customerId })`, ' +
|
|
49
|
+
'or give the plugin a default in its `stiggContext: { customerId }`.';
|
|
50
|
+
class StiggAdkPlugin extends adk_1.BasePlugin {
|
|
51
|
+
/**
|
|
52
|
+
* The graph's middleware, which every callback below drives. Protected, for a subclass that
|
|
53
|
+
* traces: what the plugin did is flushed through the SDK it was given (`stigg.flush()`), and
|
|
54
|
+
* reported through the `hooks` it was configured with.
|
|
55
|
+
*/
|
|
56
|
+
middleware;
|
|
57
|
+
stepContext;
|
|
58
|
+
onRefusal;
|
|
59
|
+
warn;
|
|
60
|
+
warned = new Set();
|
|
61
|
+
/** Model calls by their `EventActions` - the one object ADK passes to every callback of a call. */
|
|
62
|
+
callsByActions = new WeakMap();
|
|
63
|
+
/** Model calls whose usage is not reported yet, oldest first. */
|
|
64
|
+
openCalls = new Map();
|
|
65
|
+
/**
|
|
66
|
+
* Usage reports still in flight, by invocation. Each check waits for them first, so it sees the
|
|
67
|
+
* spend - including spend reported by another branch of a parallel agent.
|
|
68
|
+
*/
|
|
69
|
+
reporting = new Map();
|
|
70
|
+
/** Tool steps by their `Context`, which ADK builds once per tool call. */
|
|
71
|
+
toolRuns = new WeakMap();
|
|
72
|
+
/**
|
|
73
|
+
* The decision behind each refused tool, by invocation and function call id - kept here, away from
|
|
74
|
+
* the model. Scoped like the tool's run id, since a model may reuse call ids across turns.
|
|
75
|
+
*/
|
|
76
|
+
toolDenials = new Map();
|
|
77
|
+
/** The agents that already handed off a refused turn, by invocation. */
|
|
78
|
+
handedOff = new Map();
|
|
79
|
+
seq = 0;
|
|
80
|
+
constructor(options) {
|
|
81
|
+
const { stigg, name, stepContext, onRefusal, ...middlewareOptions } = options;
|
|
82
|
+
super(name ?? 'stigg');
|
|
83
|
+
if (!stigg || typeof stigg.middleware !== 'function') {
|
|
84
|
+
throw new Error('`StiggAdkPlugin` needs `stigg`: the `StiggAgentSDK` it governs through.');
|
|
85
|
+
}
|
|
86
|
+
if (stepContext !== undefined && typeof stepContext !== 'function') {
|
|
87
|
+
throw new Error('`stepContext` must be a function of the step, returning its `stiggContext`.');
|
|
88
|
+
}
|
|
89
|
+
if (onRefusal !== undefined && typeof onRefusal !== 'function') {
|
|
90
|
+
throw new Error('`onRefusal` must be a function of the refusal, returning what the agent answers.');
|
|
91
|
+
}
|
|
92
|
+
this.middleware = stigg.middleware({ ...middlewareOptions, missingCustomer: MISSING_CUSTOMER });
|
|
93
|
+
this.stepContext = stepContext;
|
|
94
|
+
this.onRefusal = onRefusal;
|
|
95
|
+
this.warn = (message) => stigg.config.logger.warn(message);
|
|
96
|
+
}
|
|
97
|
+
async beforeRunCallback({ invocationContext }) {
|
|
98
|
+
// A Set in ADK, kept in registration order.
|
|
99
|
+
const plugins = invocationContext.pluginManager.plugins;
|
|
100
|
+
if (plugins && [...plugins][0] !== this) {
|
|
101
|
+
this.warnOnce(`[stigg] list the Stigg plugin first in the Runner's \`plugins\`: ADK stops at the first plugin that ` +
|
|
102
|
+
'returns a value, so a plugin before it that answers a step hides that step from Stigg.');
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
async beforeModelCallback({ callbackContext, llmRequest, }) {
|
|
107
|
+
const { invocationId, agentName } = callbackContext;
|
|
108
|
+
// This agent's earlier calls are over by now. Report any still open, and wait for every report in
|
|
109
|
+
// flight, so the check below sees the spend.
|
|
110
|
+
await this.settle(invocationId, (call) => call.agent === agentName);
|
|
111
|
+
const runId = `adk:${invocationId}:${++this.seq}`;
|
|
112
|
+
const step = { agent: agentName, adkContext: callbackContext, model: llmRequest.model };
|
|
113
|
+
try {
|
|
114
|
+
await this.middleware.startModel(runId, await this.attributionOf(step), 'adk.model');
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const decision = (0, errors_1.isAccessDenied)(err);
|
|
118
|
+
if (!decision)
|
|
119
|
+
throw err;
|
|
120
|
+
return this.answerRefusal({ ...step, decision }, llmRequest, err.message);
|
|
121
|
+
}
|
|
122
|
+
const call = { runId, invocationId, agent: agentName, answered: false };
|
|
123
|
+
this.callsByActions.set(callbackContext.actions, call);
|
|
124
|
+
this.open(call);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
async afterModelCallback({ callbackContext, llmResponse, }) {
|
|
128
|
+
const call = this.callsByActions.get(callbackContext.actions);
|
|
129
|
+
if (!call || !this.openCalls.has(call.runId))
|
|
130
|
+
return undefined;
|
|
131
|
+
call.answered = true;
|
|
132
|
+
if (llmResponse.usageMetadata)
|
|
133
|
+
call.usage = llmResponse.usageMetadata;
|
|
134
|
+
// A response with a finish reason is the call's last. Report it now, before ADK hands the event
|
|
135
|
+
// to the caller - a caller that stops reading or aborts after the answer still has it reported.
|
|
136
|
+
// A streamed call's earlier responses (text so far, a function call) have none: their usage is a
|
|
137
|
+
// running total, so the call waits for its end.
|
|
138
|
+
if (llmResponse.finishReason !== undefined || llmResponse.turnComplete) {
|
|
139
|
+
await this.report([call], call.invocationId);
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
async onModelErrorCallback({ callbackContext, }) {
|
|
144
|
+
// A call that failed reports nothing, as a failed step does everywhere in the SDK.
|
|
145
|
+
const call = this.callsByActions.get(callbackContext.actions);
|
|
146
|
+
if (call && this.openCalls.delete(call.runId))
|
|
147
|
+
this.middleware.abandon(call.runId);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
async beforeToolCallback({ tool, toolArgs, toolContext, }) {
|
|
151
|
+
const { invocationId, agentName, functionCallId } = toolContext;
|
|
152
|
+
if (CONTROL_TOOLS.has(tool.name))
|
|
153
|
+
return undefined;
|
|
154
|
+
if ((0, adk_1.isAgentTool)(tool)) {
|
|
155
|
+
this.warnOnce(`[stigg] ${tool.name} is an AgentTool: ADK runs its agent in a Runner of its own, without this ` +
|
|
156
|
+
"plugin, so that agent's model calls are not checked or reported. Add it as a sub-agent instead.");
|
|
157
|
+
}
|
|
158
|
+
// Wait for usage reports in flight. Open calls are left alone: the call that asked for this tool
|
|
159
|
+
// may still be streaming.
|
|
160
|
+
await this.settle(invocationId, () => false);
|
|
161
|
+
const runId = `adk:${invocationId}:tool:${functionCallId ?? ++this.seq}`;
|
|
162
|
+
const step = { agent: agentName, adkContext: toolContext, tool: tool.name };
|
|
163
|
+
try {
|
|
164
|
+
await this.middleware.startTool(runId,
|
|
165
|
+
// Scoped to the invocation: a model that reuses call ids across turns must not merge their reports.
|
|
166
|
+
{ name: tool.name, callId: runId, input: toolArgs }, await this.attributionOf(step));
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const decision = (0, errors_1.isAccessDenied)(err);
|
|
170
|
+
if (!decision)
|
|
171
|
+
throw err;
|
|
172
|
+
if (functionCallId !== undefined)
|
|
173
|
+
keep(this.toolDenials, `${invocationId}:${functionCallId}`, decision);
|
|
174
|
+
// What the model reads: why, but not who refused or what is left - those stay with the plugin.
|
|
175
|
+
return { error: `${tool.name} is not available: ${decision.reason ?? 'access denied'}.` };
|
|
176
|
+
}
|
|
177
|
+
// An earlier refusal of a call that reused this id no longer describes it.
|
|
178
|
+
if (functionCallId !== undefined)
|
|
179
|
+
this.toolDenials.delete(`${invocationId}:${functionCallId}`);
|
|
180
|
+
this.toolRuns.set(toolContext, runId);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
// ADK calls this for a failed tool too, with `{ error }` as its result. By then
|
|
184
|
+
// `onToolErrorCallback` has forgotten the step, so nothing is reported.
|
|
185
|
+
async afterToolCallback({ tool, toolContext, result, }) {
|
|
186
|
+
const runId = this.toolRuns.get(toolContext);
|
|
187
|
+
if (runId === undefined)
|
|
188
|
+
return undefined;
|
|
189
|
+
this.toolRuns.delete(toolContext);
|
|
190
|
+
const report = this.middleware.endTool(runId, result, tool.name);
|
|
191
|
+
// Tracked like a model call's report, so a parallel branch's next check waits for it too.
|
|
192
|
+
this.track(toolContext.invocationId, report);
|
|
193
|
+
await report;
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
async onToolErrorCallback({ toolContext, }) {
|
|
197
|
+
const runId = this.toolRuns.get(toolContext);
|
|
198
|
+
if (runId === undefined)
|
|
199
|
+
return undefined;
|
|
200
|
+
this.toolRuns.delete(toolContext);
|
|
201
|
+
this.middleware.abandon(runId);
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
async afterAgentCallback({ callbackContext, }) {
|
|
205
|
+
const { invocationId, agentName } = callbackContext;
|
|
206
|
+
await this.settle(invocationId, (call) => call.agent === agentName);
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
async afterRunCallback({ invocationContext }) {
|
|
210
|
+
const { invocationId } = invocationContext;
|
|
211
|
+
this.handedOff.delete(invocationId);
|
|
212
|
+
await this.settle(invocationId, () => true);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The decision behind an event a refusal ended in - a refused model call's event (the error, or
|
|
216
|
+
* the answer `onRefusal` chose), or the function response of a refused tool - and `undefined` for
|
|
217
|
+
* any other event.
|
|
218
|
+
*/
|
|
219
|
+
denialOf(event) {
|
|
220
|
+
const refused = asDecision(event.customMetadata?.[DECISION_KEY]);
|
|
221
|
+
if (refused)
|
|
222
|
+
return refused;
|
|
223
|
+
for (const part of event.content?.parts ?? []) {
|
|
224
|
+
const id = part.functionResponse?.id;
|
|
225
|
+
const decision = id === undefined ? undefined : this.toolDenials.get(`${event.invocationId}:${id}`);
|
|
226
|
+
if (decision)
|
|
227
|
+
return decision;
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* What a refused model call answers: `onRefusal`'s choice, or the error response - with the
|
|
233
|
+
* decision in `customMetadata` either way, for `denialOf`. A transfer is a `transfer_to_agent`
|
|
234
|
+
* call, which ADK runs as if the model had made it.
|
|
235
|
+
*/
|
|
236
|
+
async answerRefusal(refusal, llmRequest, message) {
|
|
237
|
+
const withDecision = (response) => ({
|
|
238
|
+
...response,
|
|
239
|
+
customMetadata: { ...response.customMetadata, [DECISION_KEY]: refusal.decision },
|
|
240
|
+
});
|
|
241
|
+
const denied = withDecision({ errorCode: exports.DENIED_ERROR_CODE, errorMessage: message });
|
|
242
|
+
const answer = this.onRefusal ? await this.onRefusal(refusal) : undefined;
|
|
243
|
+
if (!answer)
|
|
244
|
+
return denied;
|
|
245
|
+
if (!('transferTo' in answer))
|
|
246
|
+
return withDecision(answer);
|
|
247
|
+
// A wrong target is a configuration error, so it throws rather than being answered.
|
|
248
|
+
const targets = transferTargets(llmRequest);
|
|
249
|
+
if (!targets.includes(answer.transferTo)) {
|
|
250
|
+
throw new Error(`\`onRefusal\` hands ${refusal.agent}'s turn to "${answer.transferTo}", which ${refusal.agent} cannot ` +
|
|
251
|
+
`transfer to${targets.length ? ` - it can reach ${targets.join(', ')}` : ''}.`);
|
|
252
|
+
}
|
|
253
|
+
// Once per agent per run: a refused handoff runs no model, so ADK's `maxLlmCalls` never stops
|
|
254
|
+
// two refused agents handing the turn to each other.
|
|
255
|
+
const { invocationId } = refusal.adkContext;
|
|
256
|
+
const agents = this.handedOff.get(invocationId) ?? new Set();
|
|
257
|
+
if (agents.has(refusal.agent))
|
|
258
|
+
return denied;
|
|
259
|
+
agents.add(refusal.agent);
|
|
260
|
+
keep(this.handedOff, invocationId, agents);
|
|
261
|
+
return withDecision({
|
|
262
|
+
content: {
|
|
263
|
+
role: 'model',
|
|
264
|
+
parts: [{ functionCall: { name: TRANSFER_TOOL, args: { agent_name: answer.transferTo } } }],
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** Who a step is for: the agent ADK runs it under, and the `stiggContext` `stepContext` resolves. */
|
|
269
|
+
async attributionOf(step) {
|
|
270
|
+
const stated = this.stepContext ? await this.stepContext(step) : undefined;
|
|
271
|
+
if (stated !== undefined && (typeof stated !== 'object' || stated === null || Array.isArray(stated))) {
|
|
272
|
+
throw new Error(`\`stepContext\` must return a \`stiggContext\` object, not ${String(stated)}.`);
|
|
273
|
+
}
|
|
274
|
+
return { agent: step.agent, stiggContexts: stated ? [stated] : [] };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Opens a model call. When too many are open - runs that were aborted mid-stream - the oldest is
|
|
278
|
+
* reported with the usage it has, rather than dropped.
|
|
279
|
+
*/
|
|
280
|
+
open(call) {
|
|
281
|
+
this.openCalls.set(call.runId, call);
|
|
282
|
+
if (this.openCalls.size <= MEMORY_LIMIT)
|
|
283
|
+
return;
|
|
284
|
+
const oldest = this.openCalls.values().next().value;
|
|
285
|
+
this.warnOnce(`[stigg] more than ${MEMORY_LIMIT} ADK model calls are open at once, so the oldest are reported with ` +
|
|
286
|
+
'the usage they have so far: a run that stops midway leaves its last call open.');
|
|
287
|
+
void this.report([oldest], oldest.invocationId);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Reports the invocation's open calls that `match`, then waits for every report of the invocation
|
|
291
|
+
* still in flight - its own, and ones another branch started.
|
|
292
|
+
*/
|
|
293
|
+
async settle(invocationId, match) {
|
|
294
|
+
const due = [...this.openCalls.values()].filter((call) => call.invocationId === invocationId && match(call));
|
|
295
|
+
await this.report(due, invocationId);
|
|
296
|
+
}
|
|
297
|
+
/** Reports each call once, and waits for the invocation's reports in flight. */
|
|
298
|
+
async report(calls, invocationId) {
|
|
299
|
+
for (const call of calls) {
|
|
300
|
+
this.openCalls.delete(call.runId);
|
|
301
|
+
// No response came back: something answered in the model's place, so it costs nothing.
|
|
302
|
+
if (!call.answered) {
|
|
303
|
+
this.middleware.abandon(call.runId);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
this.track(invocationId, this.middleware.endModel(call.runId, measurementOf(call)));
|
|
307
|
+
}
|
|
308
|
+
await Promise.all(this.reporting.get(invocationId) ?? []);
|
|
309
|
+
}
|
|
310
|
+
/** Keeps a usage report in flight until it lands, so the invocation's next check waits for it. */
|
|
311
|
+
track(invocationId, report) {
|
|
312
|
+
// Never rejects: a failed ingest is reported through the hooks.
|
|
313
|
+
const pending = this.reporting.get(invocationId) ?? new Set();
|
|
314
|
+
this.reporting.set(invocationId, pending);
|
|
315
|
+
pending.add(report);
|
|
316
|
+
void report.finally(() => {
|
|
317
|
+
pending.delete(report);
|
|
318
|
+
if (pending.size === 0 && this.reporting.get(invocationId) === pending)
|
|
319
|
+
this.reporting.delete(invocationId);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/** Says a thing once per process - and never fails a step for the logger's sake. */
|
|
323
|
+
warnOnce(message) {
|
|
324
|
+
if (this.warned.has(message) || this.warned.size >= 100)
|
|
325
|
+
return;
|
|
326
|
+
this.warned.add(message);
|
|
327
|
+
(0, hooks_1.safely)(() => this.warn(message), () => undefined);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
exports.StiggAdkPlugin = StiggAdkPlugin;
|
|
331
|
+
/** Adds an entry to a map, dropping the oldest past `MEMORY_LIMIT`. */
|
|
332
|
+
function keep(map, key, value) {
|
|
333
|
+
map.delete(key);
|
|
334
|
+
map.set(key, value);
|
|
335
|
+
if (map.size > MEMORY_LIMIT)
|
|
336
|
+
map.delete(map.keys().next().value);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* A call's usage as a measurement. ADK states every model's usage in Gemini's `usageMetadata`
|
|
340
|
+
* shape, whichever model served it. The call's own run id is the idempotency key, so a call
|
|
341
|
+
* reported twice counts once.
|
|
342
|
+
*/
|
|
343
|
+
function measurementOf(call) {
|
|
344
|
+
const usage = call.usage;
|
|
345
|
+
if (!usage)
|
|
346
|
+
return undefined;
|
|
347
|
+
return {
|
|
348
|
+
metrics: (0, token_usage_1.pickMetrics)(usage, token_usage_1.GEMINI_METRICS),
|
|
349
|
+
usage: (0, token_usage_1.normalizeUsage)(usage),
|
|
350
|
+
raw: usage,
|
|
351
|
+
idempotencyKey: call.runId,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The agents this call may transfer to, exactly as ADK offers them to the model: the `agent_name`
|
|
356
|
+
* values of the `transfer_to_agent` tool on the request. No tool, no targets.
|
|
357
|
+
*/
|
|
358
|
+
function transferTargets(llmRequest) {
|
|
359
|
+
const tool = llmRequest.toolsDict?.[TRANSFER_TOOL];
|
|
360
|
+
const names = tool?._getDeclaration?.()?.parameters?.properties?.agent_name?.enum;
|
|
361
|
+
return Array.isArray(names) ? names.filter((name) => typeof name === 'string') : [];
|
|
362
|
+
}
|
|
363
|
+
const asDecision = (value) => typeof value === 'object' && value !== null && value.hasAccess === false
|
|
364
|
+
? value
|
|
365
|
+
: undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@stigg/agent-sdk-js/adk` - Stigg governance for Google ADK.
|
|
3
|
+
*
|
|
4
|
+
* Its own entry point because it imports `@google/adk`, which the package's main entry never does:
|
|
5
|
+
* an application that does not run ADK never loads it. `@google/adk` is an optional peer
|
|
6
|
+
* dependency, so this uses the copy the application already has - the one whose Runner will be
|
|
7
|
+
* calling the plugin.
|
|
8
|
+
*
|
|
9
|
+
* import { StiggAgentSDK } from "@stigg/agent-sdk-js";
|
|
10
|
+
* import { StiggAdkPlugin } from "@stigg/agent-sdk-js/adk";
|
|
11
|
+
*/
|
|
12
|
+
export { StiggAdkPlugin, DENIED_ERROR_CODE } from '../bindings/orchestrator/adk/plugin';
|
|
13
|
+
export type { AdkRefusal, AdkRefusalAnswer, AdkStep, StiggAdkPluginOptions } from '../bindings/orchestrator/adk/plugin';
|
|
14
|
+
export { AccessDeniedError, isAccessDenied } from '../errors';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isAccessDenied = exports.AccessDeniedError = exports.DENIED_ERROR_CODE = exports.StiggAdkPlugin = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* `@stigg/agent-sdk-js/adk` - Stigg governance for Google ADK.
|
|
6
|
+
*
|
|
7
|
+
* Its own entry point because it imports `@google/adk`, which the package's main entry never does:
|
|
8
|
+
* an application that does not run ADK never loads it. `@google/adk` is an optional peer
|
|
9
|
+
* dependency, so this uses the copy the application already has - the one whose Runner will be
|
|
10
|
+
* calling the plugin.
|
|
11
|
+
*
|
|
12
|
+
* import { StiggAgentSDK } from "@stigg/agent-sdk-js";
|
|
13
|
+
* import { StiggAdkPlugin } from "@stigg/agent-sdk-js/adk";
|
|
14
|
+
*/
|
|
15
|
+
var plugin_1 = require("../bindings/orchestrator/adk/plugin");
|
|
16
|
+
Object.defineProperty(exports, "StiggAdkPlugin", { enumerable: true, get: function () { return plugin_1.StiggAdkPlugin; } });
|
|
17
|
+
Object.defineProperty(exports, "DENIED_ERROR_CODE", { enumerable: true, get: function () { return plugin_1.DENIED_ERROR_CODE; } });
|
|
18
|
+
// What a refusal is, for an application that catches one itself - in its own callback, say.
|
|
19
|
+
var errors_1 = require("../errors");
|
|
20
|
+
Object.defineProperty(exports, "AccessDeniedError", { enumerable: true, get: function () { return errors_1.AccessDeniedError; } });
|
|
21
|
+
Object.defineProperty(exports, "isAccessDenied", { enumerable: true, get: function () { return errors_1.isAccessDenied; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigg/agent-sdk-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"types": "./dist/integrations/langchain.d.ts",
|
|
22
22
|
"default": "./dist/integrations/langchain.js"
|
|
23
23
|
},
|
|
24
|
+
"./adk": {
|
|
25
|
+
"types": "./dist/integrations/adk.d.ts",
|
|
26
|
+
"default": "./dist/integrations/adk.js"
|
|
27
|
+
},
|
|
24
28
|
"./package.json": "./package.json"
|
|
25
29
|
}
|
|
26
30
|
},
|
|
@@ -51,6 +55,7 @@
|
|
|
51
55
|
"example-orchestrator-langchain": "tsx examples/orchestrator/langchain.ts",
|
|
52
56
|
"example-orchestrator-langgraph-handoff": "tsx examples/orchestrator/langgraph-handoff.ts",
|
|
53
57
|
"example-orchestrator-langgraph-model-fallback": "tsx examples/orchestrator/langgraph-model-fallback.ts",
|
|
58
|
+
"example-orchestrator-adk-handoff": "tsx examples/orchestrator/adk-handoff.ts",
|
|
54
59
|
"example-gateway": "tsx examples/gateway/openrouter.ts",
|
|
55
60
|
"example-gateway-openrouter": "tsx examples/gateway/openrouter.ts",
|
|
56
61
|
"example-gateway-litellm": "tsx examples/gateway/litellm.ts",
|
|
@@ -77,10 +82,14 @@
|
|
|
77
82
|
"zod": "^4.6.1"
|
|
78
83
|
},
|
|
79
84
|
"peerDependencies": {
|
|
85
|
+
"@google/adk": ">=2.3.0 <3",
|
|
80
86
|
"@langchain/core": ">=1.0.0 <2",
|
|
81
87
|
"@opentelemetry/api": "^1.9.0"
|
|
82
88
|
},
|
|
83
89
|
"peerDependenciesMeta": {
|
|
90
|
+
"@google/adk": {
|
|
91
|
+
"optional": true
|
|
92
|
+
},
|
|
84
93
|
"@langchain/core": {
|
|
85
94
|
"optional": true
|
|
86
95
|
},
|
|
@@ -92,6 +101,7 @@
|
|
|
92
101
|
"@anthropic-ai/sdk": "^0.124.0",
|
|
93
102
|
"@aws-sdk/client-bedrock-runtime": "^3.1150.0",
|
|
94
103
|
"@databricks/sdk-modelservingquery": "0.25.0",
|
|
104
|
+
"@google/adk": "^2.3.0",
|
|
95
105
|
"@google/genai": "^2.28.0",
|
|
96
106
|
"@langchain/anthropic": "^1.5.12",
|
|
97
107
|
"@langchain/core": "^1.2.18",
|
|
@@ -130,6 +140,10 @@
|
|
|
130
140
|
"types": "./dist/integrations/langchain.d.ts",
|
|
131
141
|
"default": "./dist/integrations/langchain.js"
|
|
132
142
|
},
|
|
143
|
+
"./adk": {
|
|
144
|
+
"types": "./dist/integrations/adk.d.ts",
|
|
145
|
+
"default": "./dist/integrations/adk.js"
|
|
146
|
+
},
|
|
133
147
|
"./package.json": "./package.json"
|
|
134
148
|
}
|
|
135
149
|
}
|