watchmyagents 1.2.0 → 1.3.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 +72 -0
- package/SECURITY.md +1 -1
- package/docs/adapters/openai-agents-js.md +230 -0
- package/examples/adapters/capture-fixtures.ts +193 -0
- package/examples/adapters/openai-agents-js-quickstart.ts +118 -0
- package/examples/policies/mitre-starter.json +133 -0
- package/package.json +16 -1
- package/scripts/shield.js +39 -5
- package/src/logger.js +17 -1
- package/src/sources/contract.js +31 -1
- package/src/sources/openai-agents-js.d.ts +278 -0
- package/src/sources/openai-agents-js.js +733 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
// OpenAI Agents SDK adapter — v1.3.0 (Phase 2.A).
|
|
2
|
+
//
|
|
3
|
+
// Adapter type: customer-instrumented, push-model. NOT auto-discovery.
|
|
4
|
+
//
|
|
5
|
+
// Why customer-instrumented:
|
|
6
|
+
// OpenAI's Agents SDK runs IN-PROCESS on the customer's machine — not
|
|
7
|
+
// on OpenAI's servers. There is no `listAgents` / `listConversations`
|
|
8
|
+
// equivalent that lets WMA pull events from outside. To observe an
|
|
9
|
+
// OpenAI agent, WMA code MUST run inside the customer's process,
|
|
10
|
+
// alongside the agent. This is fundamentally different from Anthropic
|
|
11
|
+
// Managed Agents (REST poll) and from AgentCore (REST poll). It is
|
|
12
|
+
// the same model Datadog APM, Sentry, Langfuse, and OpenLLMetry use
|
|
13
|
+
// for OpenAI observability. The customer wires two lines of code; the
|
|
14
|
+
// rest is automatic.
|
|
15
|
+
//
|
|
16
|
+
// Two extension points used (both OFFICIAL APIs of @openai/agents):
|
|
17
|
+
// 1. Tool Input Guardrails (Shield enforcement).
|
|
18
|
+
// `wmaToolInputGuardrail()` returns a shape-compatible
|
|
19
|
+
// `{ type: 'tool_input', name, run }` object that slots into the
|
|
20
|
+
// Agent's `toolInputGuardrails` array. When a tool is about to fire,
|
|
21
|
+
// the SDK awaits our `run()` and respects its `behavior`:
|
|
22
|
+
// - { type: 'allow' } → tool proceeds normally
|
|
23
|
+
// - { type: 'rejectContent', message } → tool is BLOCKED,
|
|
24
|
+
// message returned in
|
|
25
|
+
// place of result
|
|
26
|
+
// - { type: 'throwException' } → run aborts with an
|
|
27
|
+
// exception (kills the
|
|
28
|
+
// agent loop)
|
|
29
|
+
// 2. RunHooks EventEmitter (Watch observability).
|
|
30
|
+
// `attachWmaWatch(runner)` registers listeners on the Runner's
|
|
31
|
+
// EventEmitter for: agent_start, agent_end, agent_handoff,
|
|
32
|
+
// agent_tool_start, agent_tool_end. Each event is normalized to
|
|
33
|
+
// the WMA contract NDJSON shape and written via the Logger.
|
|
34
|
+
//
|
|
35
|
+
// Zero runtime dependency invariant:
|
|
36
|
+
// We do NOT import from '@openai/agents'. Instead we return shape-
|
|
37
|
+
// compatible plain objects. This keeps the SDK's zero-deps guarantee
|
|
38
|
+
// intact AND avoids the dynamic-import dance. The downside: any
|
|
39
|
+
// schema drift on the OpenAI side is caught only at runtime, not
|
|
40
|
+
// compile-time. We mitigate via fixture-based tests + a TypeScript
|
|
41
|
+
// .d.ts that pins the public surface.
|
|
42
|
+
//
|
|
43
|
+
// Containment invariant:
|
|
44
|
+
// Tool args + results may carry sensitive customer data. They are
|
|
45
|
+
// captured into the WMAAction's `input` / `output` fields and written
|
|
46
|
+
// to LOCAL NDJSON only. The anonymizer is the single egress gate to
|
|
47
|
+
// Fortress. Identical to Anthropic Managed adapter discipline.
|
|
48
|
+
|
|
49
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
50
|
+
import {
|
|
51
|
+
PROVIDERS, ACTION_TYPES, STATUS_VALUES,
|
|
52
|
+
COMPOSITION_PATTERNS, ENFORCEMENT_MODES,
|
|
53
|
+
} from './contract.js';
|
|
54
|
+
import { evaluate, loadPolicies } from '../shield/policy.js';
|
|
55
|
+
import { createContextTracker } from '../shield/context.js';
|
|
56
|
+
import { DecisionLogger } from '../shield/decisions.js';
|
|
57
|
+
import { Logger } from '../logger.js';
|
|
58
|
+
|
|
59
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const PROVIDER = PROVIDERS.OPENAI_AGENTS;
|
|
62
|
+
|
|
63
|
+
// We default fail-CLOSED on Shield evaluation errors. Customer can flip
|
|
64
|
+
// to fail-OPEN via options.failOpen — but that's a security regression
|
|
65
|
+
// the customer must opt into explicitly, with all the implications.
|
|
66
|
+
const DEFAULT_FAIL_OPEN = false;
|
|
67
|
+
|
|
68
|
+
// Mirror of '@openai/agents' value object factory. Replicated here so we
|
|
69
|
+
// don't take a runtime dep. If '@openai/agents' ever changes the shape
|
|
70
|
+
// (extreme low probability), fixture tests will catch it.
|
|
71
|
+
const ToolGuardrailFunctionOutputFactory = Object.freeze({
|
|
72
|
+
allow(outputInfo) {
|
|
73
|
+
return { behavior: { type: 'allow' }, outputInfo };
|
|
74
|
+
},
|
|
75
|
+
rejectContent(message, outputInfo) {
|
|
76
|
+
return { behavior: { type: 'rejectContent', message }, outputInfo };
|
|
77
|
+
},
|
|
78
|
+
throwException(outputInfo) {
|
|
79
|
+
return { behavior: { type: 'throwException' }, outputInfo };
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function nowIso() {
|
|
86
|
+
return new Date().toISOString();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readEnv(key, fallback) {
|
|
90
|
+
const v = process.env[key];
|
|
91
|
+
return v && v.length > 0 ? v : fallback;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Parse `toolCall.arguments` defensively. The OpenAI SDK serializes them
|
|
95
|
+
// as a JSON string; some custom tool implementations pass them through
|
|
96
|
+
// already-parsed. Fail-closed: if we can't parse, return null and let
|
|
97
|
+
// the policy match against {} (which fails any specific clause).
|
|
98
|
+
function safeParseToolArgs(rawArgs) {
|
|
99
|
+
if (rawArgs == null) return null;
|
|
100
|
+
if (typeof rawArgs === 'object') return rawArgs;
|
|
101
|
+
if (typeof rawArgs !== 'string') return null;
|
|
102
|
+
try { return JSON.parse(rawArgs); }
|
|
103
|
+
catch { return null; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Stable session id per run. The OpenAI SDK doesn't give us a top-level
|
|
107
|
+
// run identifier we can rely on across all event types (run-state-machine
|
|
108
|
+
// internals), so we mint our own UUID at first event and keep it.
|
|
109
|
+
function makeSessionId() {
|
|
110
|
+
return `oai-${randomUUID()}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Derive a stable, monotonic event id from the SDK's tool_call id when
|
|
114
|
+
// present (so deduplication after restart works), else mint a UUID.
|
|
115
|
+
function makeEventId(toolCall) {
|
|
116
|
+
if (toolCall && typeof toolCall === 'object') {
|
|
117
|
+
const id = toolCall.callId || toolCall.id;
|
|
118
|
+
if (typeof id === 'string' && id.length > 0) return `oai-${id}`;
|
|
119
|
+
}
|
|
120
|
+
return `oai-evt-${randomUUID()}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Hash a customer-provided tool name for use as a stable agent_id when
|
|
124
|
+
// the SDK's `agent.name` field is missing or empty. Salt is intentionally
|
|
125
|
+
// fixed (no anti-collision goal — this is just a stable derivation).
|
|
126
|
+
function fallbackAgentId(toolName) {
|
|
127
|
+
const h = createHash('sha256').update(String(toolName || 'unknown')).digest('hex');
|
|
128
|
+
return `oai-agent-${h.slice(0, 16)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Team correlation ───────────────────────────────────────────────────
|
|
132
|
+
//
|
|
133
|
+
// A "team" is a stable id shared by every event in a related group of
|
|
134
|
+
// cooperating agents. Three resolution channels, highest precedence first:
|
|
135
|
+
// 1. Customer override: WMA_TEAM_ID env var.
|
|
136
|
+
// 2. Auto-detected: when the SDK emits `agent_handoff(from, to)`, the
|
|
137
|
+
// from-agent's existing team_id propagates to the to-agent. The
|
|
138
|
+
// first agent in a run gets a freshly-minted team_id (run-scoped).
|
|
139
|
+
// 3. Null: customer didn't opt into team correlation, no handoff
|
|
140
|
+
// observed — events are tagged team_id=null. Legions UI shows them
|
|
141
|
+
// under the "Untagged" bucket.
|
|
142
|
+
|
|
143
|
+
function teamIdFromEnv() {
|
|
144
|
+
const v = readEnv('WMA_TEAM_ID', null);
|
|
145
|
+
return v && v.length > 0 ? v : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Per-run team tracker. Keyed by sessionId so multiple concurrent runs
|
|
149
|
+
// don't cross-pollinate.
|
|
150
|
+
function createTeamTracker(envTeamId) {
|
|
151
|
+
// Map<sessionId, teamId>
|
|
152
|
+
const teams = new Map();
|
|
153
|
+
return {
|
|
154
|
+
// Resolve the team_id for this event. If the customer set
|
|
155
|
+
// WMA_TEAM_ID, that always wins. Otherwise we look up the
|
|
156
|
+
// sessionId-keyed map (populated by recordHandoff() + first-event
|
|
157
|
+
// bootstrap). Returns null if no info available.
|
|
158
|
+
resolve(sessionId) {
|
|
159
|
+
if (envTeamId) return envTeamId;
|
|
160
|
+
return teams.get(sessionId) || null;
|
|
161
|
+
},
|
|
162
|
+
// First event in a run — establish a team_id. Subsequent events in
|
|
163
|
+
// the same session inherit it.
|
|
164
|
+
bootstrap(sessionId) {
|
|
165
|
+
if (envTeamId) return envTeamId;
|
|
166
|
+
let t = teams.get(sessionId);
|
|
167
|
+
if (!t) {
|
|
168
|
+
t = `oai-team-${randomUUID()}`;
|
|
169
|
+
teams.set(sessionId, t);
|
|
170
|
+
}
|
|
171
|
+
return t;
|
|
172
|
+
},
|
|
173
|
+
// SDK emitted agent_handoff(from, to) — propagate the from-agent's
|
|
174
|
+
// team to the to-agent. In Agents SDK both agents are within the
|
|
175
|
+
// same run / sessionId, so this is mostly a no-op in our model.
|
|
176
|
+
// We keep the hook for future fan-out scenarios.
|
|
177
|
+
recordHandoff(_fromAgent, _toAgent, sessionId) {
|
|
178
|
+
if (envTeamId) return envTeamId;
|
|
179
|
+
return teams.get(sessionId) || null;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Event normalization ────────────────────────────────────────────────
|
|
185
|
+
//
|
|
186
|
+
// Five event types from @openai/agents map to WMA contract action_types:
|
|
187
|
+
//
|
|
188
|
+
// agent_start → MESSAGE (with action_type override = 'agent_start')
|
|
189
|
+
// agent_end → MESSAGE (with action_type override = 'agent_end')
|
|
190
|
+
// agent_handoff → HANDOFF
|
|
191
|
+
// agent_tool_start → CUSTOM_TOOL_USE (the tool fires NEXT — capture
|
|
192
|
+
// input, no output yet)
|
|
193
|
+
// agent_tool_end → CUSTOM_TOOL_RESULT (capture output, link by
|
|
194
|
+
// tool_call_id to the start)
|
|
195
|
+
//
|
|
196
|
+
// Notes:
|
|
197
|
+
// - We pick CUSTOM_TOOL_USE over plain TOOL_USE: OpenAI Agents SDK
|
|
198
|
+
// tools are ALWAYS customer-defined (no provider-built-in shell
|
|
199
|
+
// equivalent at this surface). TOOL_USE stays reserved for provider-
|
|
200
|
+
// built-ins (Anthropic web_search, AgentCore Browser Tool, etc.).
|
|
201
|
+
// - agent_start / agent_end aren't in ACTION_TYPES today. We map them
|
|
202
|
+
// to MESSAGE so the contract validator stays green. The action_type
|
|
203
|
+
// override goes in the `output` envelope so consumers can still
|
|
204
|
+
// filter. A first-class agent lifecycle action_type may be proposed
|
|
205
|
+
// in v1.4.x.
|
|
206
|
+
|
|
207
|
+
export function normalizeAgentStart({ agent, turnInput, sessionId, teamId }) {
|
|
208
|
+
return Object.freeze({
|
|
209
|
+
id: `oai-evt-${randomUUID()}`,
|
|
210
|
+
provider: PROVIDER,
|
|
211
|
+
agent_id: agent?.name || fallbackAgentId('start'),
|
|
212
|
+
agent_name: agent?.name || null,
|
|
213
|
+
session_id: sessionId,
|
|
214
|
+
session_thread_id: sessionId,
|
|
215
|
+
action_type: ACTION_TYPES.MESSAGE,
|
|
216
|
+
timestamp: nowIso(),
|
|
217
|
+
status: STATUS_VALUES.OK,
|
|
218
|
+
tool_name: null,
|
|
219
|
+
model: agent?.model || null,
|
|
220
|
+
duration_ms: null,
|
|
221
|
+
parent_agent_id: null,
|
|
222
|
+
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
223
|
+
team_id: teamId,
|
|
224
|
+
input: turnInput ? { turn_input: turnInput } : null,
|
|
225
|
+
output: { kind: 'agent_start' },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function normalizeAgentEnd({ agent, output, sessionId, teamId }) {
|
|
230
|
+
return Object.freeze({
|
|
231
|
+
id: `oai-evt-${randomUUID()}`,
|
|
232
|
+
provider: PROVIDER,
|
|
233
|
+
agent_id: agent?.name || fallbackAgentId('end'),
|
|
234
|
+
agent_name: agent?.name || null,
|
|
235
|
+
session_id: sessionId,
|
|
236
|
+
session_thread_id: sessionId,
|
|
237
|
+
action_type: ACTION_TYPES.MESSAGE,
|
|
238
|
+
timestamp: nowIso(),
|
|
239
|
+
status: STATUS_VALUES.OK,
|
|
240
|
+
tool_name: null,
|
|
241
|
+
model: agent?.model || null,
|
|
242
|
+
duration_ms: null,
|
|
243
|
+
parent_agent_id: null,
|
|
244
|
+
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
245
|
+
team_id: teamId,
|
|
246
|
+
input: null,
|
|
247
|
+
output: { kind: 'agent_end', text: typeof output === 'string' ? output : null },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function normalizeAgentHandoff({ fromAgent, toAgent, sessionId, teamId }) {
|
|
252
|
+
return Object.freeze({
|
|
253
|
+
id: `oai-evt-${randomUUID()}`,
|
|
254
|
+
provider: PROVIDER,
|
|
255
|
+
agent_id: toAgent?.name || fallbackAgentId('handoff'),
|
|
256
|
+
agent_name: toAgent?.name || null,
|
|
257
|
+
session_id: sessionId,
|
|
258
|
+
session_thread_id: sessionId,
|
|
259
|
+
action_type: ACTION_TYPES.HANDOFF,
|
|
260
|
+
timestamp: nowIso(),
|
|
261
|
+
status: STATUS_VALUES.OK,
|
|
262
|
+
tool_name: null,
|
|
263
|
+
model: toAgent?.model || null,
|
|
264
|
+
duration_ms: null,
|
|
265
|
+
parent_agent_id: fromAgent?.name || null,
|
|
266
|
+
composition_pattern: COMPOSITION_PATTERNS.HIERARCHY,
|
|
267
|
+
team_id: teamId,
|
|
268
|
+
input: null,
|
|
269
|
+
output: {
|
|
270
|
+
kind: 'agent_handoff',
|
|
271
|
+
from: fromAgent?.name || null,
|
|
272
|
+
to: toAgent?.name || null,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId }) {
|
|
278
|
+
const parsedArgs = safeParseToolArgs(toolCall?.arguments);
|
|
279
|
+
return Object.freeze({
|
|
280
|
+
id: makeEventId(toolCall),
|
|
281
|
+
provider: PROVIDER,
|
|
282
|
+
agent_id: agent?.name || fallbackAgentId(tool?.name),
|
|
283
|
+
agent_name: agent?.name || null,
|
|
284
|
+
session_id: sessionId,
|
|
285
|
+
session_thread_id: sessionId,
|
|
286
|
+
action_type: ACTION_TYPES.CUSTOM_TOOL_USE,
|
|
287
|
+
timestamp: nowIso(),
|
|
288
|
+
status: STATUS_VALUES.OK,
|
|
289
|
+
tool_name: tool?.name || null,
|
|
290
|
+
model: agent?.model || null,
|
|
291
|
+
duration_ms: null,
|
|
292
|
+
parent_agent_id: null,
|
|
293
|
+
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
294
|
+
team_id: teamId,
|
|
295
|
+
input: parsedArgs,
|
|
296
|
+
output: null,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, teamId }) {
|
|
301
|
+
return Object.freeze({
|
|
302
|
+
id: `${makeEventId(toolCall)}-end`,
|
|
303
|
+
provider: PROVIDER,
|
|
304
|
+
agent_id: agent?.name || fallbackAgentId(tool?.name),
|
|
305
|
+
agent_name: agent?.name || null,
|
|
306
|
+
session_id: sessionId,
|
|
307
|
+
session_thread_id: sessionId,
|
|
308
|
+
action_type: ACTION_TYPES.CUSTOM_TOOL_RESULT,
|
|
309
|
+
timestamp: nowIso(),
|
|
310
|
+
status: STATUS_VALUES.OK,
|
|
311
|
+
tool_name: tool?.name || null,
|
|
312
|
+
model: agent?.model || null,
|
|
313
|
+
duration_ms: null,
|
|
314
|
+
parent_agent_id: null,
|
|
315
|
+
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
316
|
+
team_id: teamId,
|
|
317
|
+
input: null,
|
|
318
|
+
output: typeof result === 'string' ? { text: result } : { value: result },
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Public API: Shield (Tool Input Guardrail) ──────────────────────────
|
|
323
|
+
//
|
|
324
|
+
// Returns a guardrail object SHAPE-COMPATIBLE with the @openai/agents
|
|
325
|
+
// `defineToolInputGuardrail()` output. Customer drops it into an Agent's
|
|
326
|
+
// `toolInputGuardrails: [...]` and the SDK runs it before every tool
|
|
327
|
+
// call.
|
|
328
|
+
//
|
|
329
|
+
// Options:
|
|
330
|
+
// policiesPath: string — local JSON policy file path
|
|
331
|
+
// ruleset: object — in-memory policy ruleset (overrides path)
|
|
332
|
+
// logDir: string — NDJSON log dir (default: WMA_LOG_DIR or
|
|
333
|
+
// ./watchmyagents-logs)
|
|
334
|
+
// sessionId: string — override session id (default: auto-minted)
|
|
335
|
+
// recentWindowSize:number — context tracker window (default: 20)
|
|
336
|
+
// failOpen: boolean — on Shield internal error, allow vs deny
|
|
337
|
+
// (default: false = fail-CLOSED)
|
|
338
|
+
// getTeamId: fn()→string|null — custom team resolver
|
|
339
|
+
// logger: Logger — inject an existing Logger (testing)
|
|
340
|
+
// decisionLogger: DecisionLogger — inject existing (testing)
|
|
341
|
+
// tracker: ContextTracker — inject existing (testing)
|
|
342
|
+
//
|
|
343
|
+
// All options are optional; defaults read from env. Lazy loading of the
|
|
344
|
+
// policy ruleset on first invocation if `policiesPath` is set.
|
|
345
|
+
|
|
346
|
+
export function wmaToolInputGuardrail(options = {}) {
|
|
347
|
+
const failOpen = options.failOpen === true ? true : DEFAULT_FAIL_OPEN;
|
|
348
|
+
const sessionId = options.sessionId || makeSessionId();
|
|
349
|
+
const logDir = options.logDir
|
|
350
|
+
|| readEnv('WMA_LOG_DIR', './watchmyagents-logs');
|
|
351
|
+
|
|
352
|
+
// Set up the shared shield state. Re-used across all tool calls
|
|
353
|
+
// through this guardrail instance.
|
|
354
|
+
let ruleset = options.ruleset || null;
|
|
355
|
+
const tracker = options.tracker
|
|
356
|
+
|| createContextTracker({ recentWindowSize: options.recentWindowSize ?? 20 });
|
|
357
|
+
const decisionLogger = options.decisionLogger
|
|
358
|
+
|| new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId });
|
|
359
|
+
const logger = options.logger
|
|
360
|
+
|| new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: false });
|
|
361
|
+
|
|
362
|
+
// Team tracker — re-used for the entire run of this guardrail.
|
|
363
|
+
const envTeamId = teamIdFromEnv();
|
|
364
|
+
const teamTracker = createTeamTracker(envTeamId);
|
|
365
|
+
|
|
366
|
+
const getTeamId = typeof options.getTeamId === 'function'
|
|
367
|
+
? options.getTeamId
|
|
368
|
+
: () => teamTracker.bootstrap(sessionId);
|
|
369
|
+
|
|
370
|
+
// Lazily load the ruleset on first invocation if a path was given.
|
|
371
|
+
async function ensureRuleset() {
|
|
372
|
+
if (ruleset != null) return ruleset;
|
|
373
|
+
if (options.policiesPath) {
|
|
374
|
+
ruleset = await loadPolicies(options.policiesPath);
|
|
375
|
+
return ruleset;
|
|
376
|
+
}
|
|
377
|
+
// No ruleset, no path → default to "always allow". The customer
|
|
378
|
+
// probably means to use Fortress; we don't ship that wiring in
|
|
379
|
+
// v1.3.0 from this entry point. Log a warning once.
|
|
380
|
+
if (!ensureRuleset._warned) {
|
|
381
|
+
ensureRuleset._warned = true;
|
|
382
|
+
process.stderr.write(
|
|
383
|
+
'[wma/openai-agents] no policy ruleset configured — guardrail will allow all. ' +
|
|
384
|
+
'Pass { policiesPath } or { ruleset } to wmaToolInputGuardrail() to enforce.\n',
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
ruleset = { policies: [], default: { action: 'allow' } };
|
|
388
|
+
return ruleset;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
type: 'tool_input',
|
|
393
|
+
name: 'watchmyagents-shield',
|
|
394
|
+
run: async (data) => {
|
|
395
|
+
// Defensive: data may be { context, agent, toolCall }.
|
|
396
|
+
const agent = data?.agent || null;
|
|
397
|
+
const toolCall = data?.toolCall || null;
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const rs = await ensureRuleset();
|
|
401
|
+
|
|
402
|
+
// 1. Normalize the about-to-fire tool_use event.
|
|
403
|
+
const event = normalizeToolStart({
|
|
404
|
+
agent,
|
|
405
|
+
tool: { name: toolCall?.name },
|
|
406
|
+
toolCall,
|
|
407
|
+
sessionId,
|
|
408
|
+
teamId: getTeamId(),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// 2. Compute policy context BEFORE evaluation so recent_error_rate
|
|
412
|
+
// and friends reflect prior history.
|
|
413
|
+
const policyCtx = tracker.compute(event);
|
|
414
|
+
|
|
415
|
+
// 3. Evaluate Shield.
|
|
416
|
+
const t0 = Date.now();
|
|
417
|
+
const result = evaluate(event, rs, policyCtx);
|
|
418
|
+
const decidedInMs = Date.now() - t0;
|
|
419
|
+
|
|
420
|
+
// 4. Audit-grade log (chain-signed).
|
|
421
|
+
await decisionLogger.record({
|
|
422
|
+
sourceEvent: { id: event.id, type: event.action_type, tool_name: event.tool_name, input: event.input },
|
|
423
|
+
decision: result.decision,
|
|
424
|
+
ruleId: result.rule_id,
|
|
425
|
+
ruleName: result.rule_name,
|
|
426
|
+
message: result.message,
|
|
427
|
+
decidedInMs,
|
|
428
|
+
mode: result.mode,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// 5. Watch log (the event itself, separate from the decision).
|
|
432
|
+
await logger.write(event);
|
|
433
|
+
|
|
434
|
+
// 6. Record outcome into the context tracker for the next event.
|
|
435
|
+
// On a Shield block, the tool won't run — that's NOT a tool
|
|
436
|
+
// error from the agent's perspective. Use the default
|
|
437
|
+
// heuristic which won't mark allow/deny as errors.
|
|
438
|
+
tracker.record(event);
|
|
439
|
+
|
|
440
|
+
// 7. Translate Shield decision into OpenAI guardrail behavior.
|
|
441
|
+
// Shadow mode never blocks — log + allow.
|
|
442
|
+
if (result.mode === 'shadow') {
|
|
443
|
+
return ToolGuardrailFunctionOutputFactory.allow({
|
|
444
|
+
wma: { rule_id: result.rule_id, mode: 'shadow', shadow_decision: result.decision },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (result.decision === 'deny') {
|
|
448
|
+
return ToolGuardrailFunctionOutputFactory.rejectContent(
|
|
449
|
+
result.message || `Blocked by ${result.rule_name || 'WMA Shield'}`,
|
|
450
|
+
{ wma: { rule_id: result.rule_id, rule_name: result.rule_name } },
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (result.decision === 'interrupt') {
|
|
454
|
+
return ToolGuardrailFunctionOutputFactory.throwException({
|
|
455
|
+
wma: { rule_id: result.rule_id, rule_name: result.rule_name, message: result.message },
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return ToolGuardrailFunctionOutputFactory.allow({
|
|
459
|
+
wma: { rule_id: null, rule_name: '(default-allow)' },
|
|
460
|
+
});
|
|
461
|
+
} catch (err) {
|
|
462
|
+
// Shield internal failure (policy file unreadable, disk full on
|
|
463
|
+
// log write, etc.). Default fail-CLOSED unless customer opted
|
|
464
|
+
// into fail-OPEN.
|
|
465
|
+
process.stderr.write(
|
|
466
|
+
`[wma/openai-agents] guardrail error: ${err.message}\n`,
|
|
467
|
+
);
|
|
468
|
+
if (failOpen) {
|
|
469
|
+
return ToolGuardrailFunctionOutputFactory.allow({
|
|
470
|
+
wma: { error: err.message, failOpenApplied: true },
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return ToolGuardrailFunctionOutputFactory.rejectContent(
|
|
474
|
+
`WMA Shield error: ${err.message} (fail-closed)`,
|
|
475
|
+
{ wma: { error: err.message, failOpenApplied: false } },
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Public API: Watch (RunHooks listener attach) ───────────────────────
|
|
483
|
+
//
|
|
484
|
+
// Attaches WMA listeners to a Runner (or any EventEmitter-shaped object
|
|
485
|
+
// with `.on(event, listener)`). Captures all 5 lifecycle events,
|
|
486
|
+
// normalizes each, and writes to local NDJSON. Returns an unsubscribe
|
|
487
|
+
// function the customer can call on cleanup.
|
|
488
|
+
//
|
|
489
|
+
// Why a separate function from the guardrail:
|
|
490
|
+
// The guardrail is OPTIONAL (customer might want Watch-only — pure
|
|
491
|
+
// observability with no enforcement). The Watch attach is the
|
|
492
|
+
// complementary opt-in. Both are independent.
|
|
493
|
+
|
|
494
|
+
export function attachWmaWatch(runner, options = {}) {
|
|
495
|
+
if (!runner || typeof runner.on !== 'function') {
|
|
496
|
+
throw new TypeError(
|
|
497
|
+
'attachWmaWatch: runner must expose an .on(event, listener) method ' +
|
|
498
|
+
'(the @openai/agents Runner EventEmitter, or a compatible shim).',
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const sessionId = options.sessionId || makeSessionId();
|
|
503
|
+
const logDir = options.logDir
|
|
504
|
+
|| readEnv('WMA_LOG_DIR', './watchmyagents-logs');
|
|
505
|
+
const logger = options.logger
|
|
506
|
+
|| new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: true });
|
|
507
|
+
|
|
508
|
+
const envTeamId = teamIdFromEnv();
|
|
509
|
+
const teamTracker = options.teamTracker || createTeamTracker(envTeamId);
|
|
510
|
+
|
|
511
|
+
// Per-event handlers. Each one normalizes then writes. We wrap each in
|
|
512
|
+
// try/catch — Watch must NEVER throw inside the customer's Runner
|
|
513
|
+
// event loop. Watch is observation; observation must be best-effort.
|
|
514
|
+
const handlers = {
|
|
515
|
+
agent_start: async (context, agent, turnInput) => {
|
|
516
|
+
try {
|
|
517
|
+
const teamId = teamTracker.bootstrap(sessionId);
|
|
518
|
+
const event = normalizeAgentStart({ agent, turnInput, sessionId, teamId });
|
|
519
|
+
await logger.write(event);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
process.stderr.write(`[wma/openai-agents] watch agent_start error: ${e.message}\n`);
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
agent_end: async (context, agent, output) => {
|
|
525
|
+
try {
|
|
526
|
+
const teamId = teamTracker.resolve(sessionId);
|
|
527
|
+
const event = normalizeAgentEnd({ agent, output, sessionId, teamId });
|
|
528
|
+
await logger.write(event);
|
|
529
|
+
} catch (e) {
|
|
530
|
+
process.stderr.write(`[wma/openai-agents] watch agent_end error: ${e.message}\n`);
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
agent_handoff: async (context, fromAgent, toAgent) => {
|
|
534
|
+
try {
|
|
535
|
+
const teamId = teamTracker.recordHandoff(fromAgent, toAgent, sessionId)
|
|
536
|
+
|| teamTracker.bootstrap(sessionId);
|
|
537
|
+
const event = normalizeAgentHandoff({ fromAgent, toAgent, sessionId, teamId });
|
|
538
|
+
await logger.write(event);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
process.stderr.write(`[wma/openai-agents] watch agent_handoff error: ${e.message}\n`);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
agent_tool_start: async (context, agent, tool, details) => {
|
|
544
|
+
try {
|
|
545
|
+
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
546
|
+
const event = normalizeToolStart({
|
|
547
|
+
agent, tool, toolCall: details?.toolCall, sessionId, teamId,
|
|
548
|
+
});
|
|
549
|
+
await logger.write(event);
|
|
550
|
+
} catch (e) {
|
|
551
|
+
process.stderr.write(`[wma/openai-agents] watch agent_tool_start error: ${e.message}\n`);
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
agent_tool_end: async (context, agent, tool, result, details) => {
|
|
555
|
+
try {
|
|
556
|
+
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
557
|
+
const event = normalizeToolEnd({
|
|
558
|
+
agent, tool, result, toolCall: details?.toolCall, sessionId, teamId,
|
|
559
|
+
});
|
|
560
|
+
await logger.write(event);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
process.stderr.write(`[wma/openai-agents] watch agent_tool_end error: ${e.message}\n`);
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
for (const [evt, fn] of Object.entries(handlers)) {
|
|
568
|
+
runner.on(evt, fn);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Unsubscribe helper. If `runner.off` exists (standard EventEmitter),
|
|
572
|
+
// detach. Otherwise no-op. Customer should call this on shutdown.
|
|
573
|
+
return function detachWmaWatch() {
|
|
574
|
+
if (typeof runner.off !== 'function') return;
|
|
575
|
+
for (const [evt, fn] of Object.entries(handlers)) {
|
|
576
|
+
try { runner.off(evt, fn); }
|
|
577
|
+
catch { /* listener wasn't attached or off() not supported */ }
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── Public API: Watch via AgentHooks (when customer uses run() helper) ─
|
|
583
|
+
//
|
|
584
|
+
// The @openai/agents SDK exposes TWO event-emitter surfaces:
|
|
585
|
+
// - RunHooks : `runner.on(event, listener)` — fires when customer
|
|
586
|
+
// explicitly creates a Runner via `new Runner()`.
|
|
587
|
+
// Listener args INCLUDE the agent.
|
|
588
|
+
// - AgentHooks: `agent.on(event, listener)` — fires when customer
|
|
589
|
+
// uses the convenience `run(agent, ...)` function
|
|
590
|
+
// (without an explicit Runner). The agent is implicit
|
|
591
|
+
// (it's the one we registered listeners on), so the
|
|
592
|
+
// listener args do NOT include it (except agent_start).
|
|
593
|
+
//
|
|
594
|
+
// Real-world capture (June 2026) shows AgentHooks signatures:
|
|
595
|
+
// agent_start [context, agent, turnInput?]
|
|
596
|
+
// agent_end [context, output] ← no agent
|
|
597
|
+
// agent_handoff [context, toAgent] ← only the "to" side
|
|
598
|
+
// agent_tool_start [context, tool, details] ← no agent
|
|
599
|
+
// agent_tool_end [context, tool, result, details] ← no agent
|
|
600
|
+
//
|
|
601
|
+
// `attachWmaWatchToAgent(agent, options)` mirrors `attachWmaWatch`
|
|
602
|
+
// for the AgentHooks pattern. The agent is captured via closure so
|
|
603
|
+
// our normalizers still receive an agent_id even for events that
|
|
604
|
+
// don't pass the agent in their args.
|
|
605
|
+
//
|
|
606
|
+
// Returns a `detach()` function symmetric to attachWmaWatch.
|
|
607
|
+
|
|
608
|
+
export function attachWmaWatchToAgent(agent, options = {}) {
|
|
609
|
+
if (!agent || typeof agent.on !== 'function') {
|
|
610
|
+
throw new TypeError(
|
|
611
|
+
'attachWmaWatchToAgent: agent must expose an .on(event, listener) method ' +
|
|
612
|
+
'(the @openai/agents Agent EventEmitter, or a compatible shim).',
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const sessionId = options.sessionId || makeSessionId();
|
|
617
|
+
const logDir = options.logDir
|
|
618
|
+
|| readEnv('WMA_LOG_DIR', './watchmyagents-logs');
|
|
619
|
+
const logger = options.logger
|
|
620
|
+
|| new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: true });
|
|
621
|
+
|
|
622
|
+
const envTeamId = teamIdFromEnv();
|
|
623
|
+
const teamTracker = options.teamTracker || createTeamTracker(envTeamId);
|
|
624
|
+
|
|
625
|
+
// Same try/catch wrapping discipline as attachWmaWatch: Watch must
|
|
626
|
+
// NEVER throw inside the customer's agent loop. Handlers below absorb
|
|
627
|
+
// exceptions and write them to stderr instead of propagating.
|
|
628
|
+
const handlers = {
|
|
629
|
+
agent_start: async (context, eventAgent, turnInput) => {
|
|
630
|
+
try {
|
|
631
|
+
const teamId = teamTracker.bootstrap(sessionId);
|
|
632
|
+
const event = normalizeAgentStart({
|
|
633
|
+
agent: eventAgent || agent,
|
|
634
|
+
turnInput, sessionId, teamId,
|
|
635
|
+
});
|
|
636
|
+
await logger.write(event);
|
|
637
|
+
} catch (e) {
|
|
638
|
+
process.stderr.write(`[wma/openai-agents] watch agent_start error: ${e.message}\n`);
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
// AgentHooks agent_end does NOT pass the agent — captured via closure.
|
|
642
|
+
agent_end: async (context, output) => {
|
|
643
|
+
try {
|
|
644
|
+
const teamId = teamTracker.resolve(sessionId);
|
|
645
|
+
const event = normalizeAgentEnd({ agent, output, sessionId, teamId });
|
|
646
|
+
await logger.write(event);
|
|
647
|
+
} catch (e) {
|
|
648
|
+
process.stderr.write(`[wma/openai-agents] watch agent_end error: ${e.message}\n`);
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
// AgentHooks agent_handoff passes only the "to" agent. The "from"
|
|
652
|
+
// is the agent we registered on (this listener is fired right
|
|
653
|
+
// before control passes away from it).
|
|
654
|
+
agent_handoff: async (context, toAgent) => {
|
|
655
|
+
try {
|
|
656
|
+
const teamId = teamTracker.recordHandoff(agent, toAgent, sessionId)
|
|
657
|
+
|| teamTracker.bootstrap(sessionId);
|
|
658
|
+
const event = normalizeAgentHandoff({
|
|
659
|
+
fromAgent: agent, toAgent, sessionId, teamId,
|
|
660
|
+
});
|
|
661
|
+
await logger.write(event);
|
|
662
|
+
} catch (e) {
|
|
663
|
+
process.stderr.write(`[wma/openai-agents] watch agent_handoff error: ${e.message}\n`);
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
// AgentHooks tool events do NOT pass the agent — captured via closure.
|
|
667
|
+
agent_tool_start: async (context, tool, details) => {
|
|
668
|
+
try {
|
|
669
|
+
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
670
|
+
const event = normalizeToolStart({
|
|
671
|
+
agent, tool, toolCall: details?.toolCall, sessionId, teamId,
|
|
672
|
+
});
|
|
673
|
+
await logger.write(event);
|
|
674
|
+
} catch (e) {
|
|
675
|
+
process.stderr.write(`[wma/openai-agents] watch agent_tool_start error: ${e.message}\n`);
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
agent_tool_end: async (context, tool, result, details) => {
|
|
679
|
+
try {
|
|
680
|
+
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
681
|
+
const event = normalizeToolEnd({
|
|
682
|
+
agent, tool, result, toolCall: details?.toolCall, sessionId, teamId,
|
|
683
|
+
});
|
|
684
|
+
await logger.write(event);
|
|
685
|
+
} catch (e) {
|
|
686
|
+
process.stderr.write(`[wma/openai-agents] watch agent_tool_end error: ${e.message}\n`);
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
for (const [evt, fn] of Object.entries(handlers)) {
|
|
692
|
+
agent.on(evt, fn);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return function detachWmaWatchFromAgent() {
|
|
696
|
+
if (typeof agent.off !== 'function') return;
|
|
697
|
+
for (const [evt, fn] of Object.entries(handlers)) {
|
|
698
|
+
try { agent.off(evt, fn); }
|
|
699
|
+
catch { /* listener wasn't attached or off() not supported */ }
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Enforcement metadata (for adapter registry / introspection) ───────
|
|
705
|
+
|
|
706
|
+
export const adapterMeta = Object.freeze({
|
|
707
|
+
provider: PROVIDER,
|
|
708
|
+
displayName: 'OpenAI Agents SDK',
|
|
709
|
+
enforcement: ENFORCEMENT_MODES.SYNC_CONFIRM,
|
|
710
|
+
compositionDefault: COMPOSITION_PATTERNS.SOLO,
|
|
711
|
+
customerInstrumented: true, // not auto-discovery
|
|
712
|
+
peerPackage: '@openai/agents',
|
|
713
|
+
minPeerVersion: '0.1.0', // adjust after testing
|
|
714
|
+
capabilities: Object.freeze({
|
|
715
|
+
watch: true,
|
|
716
|
+
shield: true,
|
|
717
|
+
preToolDeny: true, // via ToolInputGuardrail
|
|
718
|
+
postToolFilter: false, // ToolOutputGuardrail later
|
|
719
|
+
teamIdAutoDetect: true, // via agent_handoff
|
|
720
|
+
streamingSupported: 'verify-day-2', // verify in smoke test
|
|
721
|
+
}),
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// Internal-only — exported for tests. The factory mirrors @openai/agents'
|
|
725
|
+
// public symbol; tests can assert against it.
|
|
726
|
+
export const __testing__ = Object.freeze({
|
|
727
|
+
ToolGuardrailFunctionOutputFactory,
|
|
728
|
+
safeParseToolArgs,
|
|
729
|
+
makeSessionId,
|
|
730
|
+
makeEventId,
|
|
731
|
+
fallbackAgentId,
|
|
732
|
+
createTeamTracker,
|
|
733
|
+
});
|