watchmyagents 1.2.1 → 1.4.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/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/package.json +23 -1
- package/src/anonymizer.js +48 -0
- package/src/index.js +52 -0
- package/src/logger.js +17 -1
- package/src/openai-agents.js +202 -0
- package/src/shield/decisions.js +12 -2
- package/src/sources/contract.js +31 -1
- package/src/sources/openai-agents-js.d.ts +278 -0
- package/src/sources/openai-agents-js.js +872 -0
|
@@ -0,0 +1,872 @@
|
|
|
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
|
+
import { normalizeToolInput } from '../anonymizer.js';
|
|
59
|
+
|
|
60
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const PROVIDER = PROVIDERS.OPENAI_AGENTS;
|
|
63
|
+
|
|
64
|
+
// We default fail-CLOSED on Shield evaluation errors. Customer can flip
|
|
65
|
+
// to fail-OPEN via options.failOpen — but that's a security regression
|
|
66
|
+
// the customer must opt into explicitly, with all the implications.
|
|
67
|
+
const DEFAULT_FAIL_OPEN = false;
|
|
68
|
+
|
|
69
|
+
// v1.4 F-32 (P2 Codex audit on v1.3.0): bound the bytes we accept on
|
|
70
|
+
// the hot path. A misbehaving tool, a model hallucinating a huge
|
|
71
|
+
// response, or a malicious customer-controlled fixture can otherwise
|
|
72
|
+
// pin CPU on JSON.parse, blow up the NDJSON line, or fill the disk.
|
|
73
|
+
// 256 KB is generous for legitimate tool inputs/outputs (real-world
|
|
74
|
+
// captures from the Guardian agent peak at < 2 KB) and well under what
|
|
75
|
+
// would degrade Watch / Shield. Customers with outlier traffic can
|
|
76
|
+
// override per-guardrail via options.maxArgBytes / options.maxResultBytes.
|
|
77
|
+
const DEFAULT_MAX_ARG_BYTES = 256 * 1024;
|
|
78
|
+
const DEFAULT_MAX_RESULT_BYTES = 256 * 1024;
|
|
79
|
+
const TRUNCATION_SENTINEL = '…[truncated by WMA Shield]';
|
|
80
|
+
|
|
81
|
+
// Mirror of '@openai/agents' value object factory. Replicated here so we
|
|
82
|
+
// don't take a runtime dep. If '@openai/agents' ever changes the shape
|
|
83
|
+
// (extreme low probability), fixture tests will catch it.
|
|
84
|
+
const ToolGuardrailFunctionOutputFactory = Object.freeze({
|
|
85
|
+
allow(outputInfo) {
|
|
86
|
+
return { behavior: { type: 'allow' }, outputInfo };
|
|
87
|
+
},
|
|
88
|
+
rejectContent(message, outputInfo) {
|
|
89
|
+
return { behavior: { type: 'rejectContent', message }, outputInfo };
|
|
90
|
+
},
|
|
91
|
+
throwException(outputInfo) {
|
|
92
|
+
return { behavior: { type: 'throwException' }, outputInfo };
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function nowIso() {
|
|
99
|
+
return new Date().toISOString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readEnv(key, fallback) {
|
|
103
|
+
const v = process.env[key];
|
|
104
|
+
return v && v.length > 0 ? v : fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Parse `toolCall.arguments` defensively. The OpenAI SDK serializes them
|
|
108
|
+
// as a JSON string; some custom tool implementations pass them through
|
|
109
|
+
// already-parsed. Fail-closed: if we can't parse, return null and let
|
|
110
|
+
// the policy match against {} (which fails any specific clause).
|
|
111
|
+
//
|
|
112
|
+
// v1.4 F-32 — cap the input bytes before JSON.parse. Beyond the cap
|
|
113
|
+
// the input is truncated with a sentinel and a parsed shape that
|
|
114
|
+
// preserves the field structure as much as possible (try-parse the
|
|
115
|
+
// truncated text, fall back to `{ _wmaTruncated: true, original_bytes }`).
|
|
116
|
+
// Policies that match on tool_name still work; policies that match on
|
|
117
|
+
// argument values silently miss the truncated suffix — which is the
|
|
118
|
+
// correct fail-closed behavior for an oversize input.
|
|
119
|
+
function safeParseToolArgs(rawArgs, maxBytes = DEFAULT_MAX_ARG_BYTES) {
|
|
120
|
+
if (rawArgs == null) return null;
|
|
121
|
+
if (typeof rawArgs === 'object') {
|
|
122
|
+
// Already parsed by the SDK or the customer. We don't deep-walk
|
|
123
|
+
// and truncate — that would silently change policy match semantics
|
|
124
|
+
// on nested fields. Defer the decision to the caller; the size cap
|
|
125
|
+
// applies at the string-parse boundary, which is the realistic
|
|
126
|
+
// SDK entry point.
|
|
127
|
+
return rawArgs;
|
|
128
|
+
}
|
|
129
|
+
if (typeof rawArgs !== 'string') return null;
|
|
130
|
+
// Byte cap: Buffer.byteLength is the safe length on the wire.
|
|
131
|
+
const byteLen = Buffer.byteLength(rawArgs, 'utf8');
|
|
132
|
+
if (byteLen > maxBytes) {
|
|
133
|
+
// Truncate to maxBytes worth of bytes (substring is char-bounded;
|
|
134
|
+
// good enough — exact byte truncation isn't required since we mark
|
|
135
|
+
// the result as truncated and never feed it back to a real tool).
|
|
136
|
+
const head = rawArgs.slice(0, maxBytes);
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(head);
|
|
139
|
+
// If by luck the head is valid JSON, return it plus the marker.
|
|
140
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
141
|
+
return { ...parsed, _wmaTruncated: true, _wmaOriginalBytes: byteLen };
|
|
142
|
+
}
|
|
143
|
+
return { _wmaTruncated: true, _wmaOriginalBytes: byteLen, head: parsed };
|
|
144
|
+
} catch {
|
|
145
|
+
// Common path: truncated JSON is invalid mid-tree.
|
|
146
|
+
return { _wmaTruncated: true, _wmaOriginalBytes: byteLen };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
try { return JSON.parse(rawArgs); }
|
|
150
|
+
catch { return null; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Stable session id per run. The OpenAI SDK doesn't give us a top-level
|
|
154
|
+
// run identifier we can rely on across all event types (run-state-machine
|
|
155
|
+
// internals), so we mint our own UUID at first event and keep it.
|
|
156
|
+
function makeSessionId() {
|
|
157
|
+
return `oai-${randomUUID()}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// v1.4 F-31 — short reference code minted per Shield internal error.
|
|
161
|
+
// Returned to the model as `Ref: WMA-SHL-<8hex>` and logged to stderr
|
|
162
|
+
// alongside the full err.message so an operator can correlate the
|
|
163
|
+
// generic model-facing message to the local detailed log without
|
|
164
|
+
// leaking the raw error to the model.
|
|
165
|
+
function makeErrorRef() {
|
|
166
|
+
// 8 hex chars from a fresh UUID — wide enough to deconflict within
|
|
167
|
+
// a session, short enough to be readable in a tool result.
|
|
168
|
+
return `WMA-SHL-${randomUUID().replace(/-/g, '').slice(0, 8)}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Derive a stable, monotonic event id from the SDK's tool_call id when
|
|
172
|
+
// present (so deduplication after restart works), else mint a UUID.
|
|
173
|
+
function makeEventId(toolCall) {
|
|
174
|
+
if (toolCall && typeof toolCall === 'object') {
|
|
175
|
+
const id = toolCall.callId || toolCall.id;
|
|
176
|
+
if (typeof id === 'string' && id.length > 0) return `oai-${id}`;
|
|
177
|
+
}
|
|
178
|
+
return `oai-evt-${randomUUID()}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Hash a customer-provided tool name for use as a stable agent_id when
|
|
182
|
+
// the SDK's `agent.name` field is missing or empty. Salt is intentionally
|
|
183
|
+
// fixed (no anti-collision goal — this is just a stable derivation).
|
|
184
|
+
function fallbackAgentId(toolName) {
|
|
185
|
+
const h = createHash('sha256').update(String(toolName || 'unknown')).digest('hex');
|
|
186
|
+
return `oai-agent-${h.slice(0, 16)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Team correlation ───────────────────────────────────────────────────
|
|
190
|
+
//
|
|
191
|
+
// A "team" is a stable id shared by every event in a related group of
|
|
192
|
+
// cooperating agents. Three resolution channels, highest precedence first:
|
|
193
|
+
// 1. Customer override: WMA_TEAM_ID env var.
|
|
194
|
+
// 2. Auto-detected: when the SDK emits `agent_handoff(from, to)`, the
|
|
195
|
+
// from-agent's existing team_id propagates to the to-agent. The
|
|
196
|
+
// first agent in a run gets a freshly-minted team_id (run-scoped).
|
|
197
|
+
// 3. Null: customer didn't opt into team correlation, no handoff
|
|
198
|
+
// observed — events are tagged team_id=null. Legions UI shows them
|
|
199
|
+
// under the "Untagged" bucket.
|
|
200
|
+
|
|
201
|
+
function teamIdFromEnv() {
|
|
202
|
+
const v = readEnv('WMA_TEAM_ID', null);
|
|
203
|
+
return v && v.length > 0 ? v : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Per-run team tracker. Keyed by sessionId so multiple concurrent runs
|
|
207
|
+
// don't cross-pollinate.
|
|
208
|
+
function createTeamTracker(envTeamId) {
|
|
209
|
+
// Map<sessionId, teamId>
|
|
210
|
+
const teams = new Map();
|
|
211
|
+
return {
|
|
212
|
+
// Resolve the team_id for this event. If the customer set
|
|
213
|
+
// WMA_TEAM_ID, that always wins. Otherwise we look up the
|
|
214
|
+
// sessionId-keyed map (populated by recordHandoff() + first-event
|
|
215
|
+
// bootstrap). Returns null if no info available.
|
|
216
|
+
resolve(sessionId) {
|
|
217
|
+
if (envTeamId) return envTeamId;
|
|
218
|
+
return teams.get(sessionId) || null;
|
|
219
|
+
},
|
|
220
|
+
// First event in a run — establish a team_id. Subsequent events in
|
|
221
|
+
// the same session inherit it.
|
|
222
|
+
bootstrap(sessionId) {
|
|
223
|
+
if (envTeamId) return envTeamId;
|
|
224
|
+
let t = teams.get(sessionId);
|
|
225
|
+
if (!t) {
|
|
226
|
+
t = `oai-team-${randomUUID()}`;
|
|
227
|
+
teams.set(sessionId, t);
|
|
228
|
+
}
|
|
229
|
+
return t;
|
|
230
|
+
},
|
|
231
|
+
// SDK emitted agent_handoff(from, to) — propagate the from-agent's
|
|
232
|
+
// team to the to-agent. In Agents SDK both agents are within the
|
|
233
|
+
// same run / sessionId, so this is mostly a no-op in our model.
|
|
234
|
+
// We keep the hook for future fan-out scenarios.
|
|
235
|
+
recordHandoff(_fromAgent, _toAgent, sessionId) {
|
|
236
|
+
if (envTeamId) return envTeamId;
|
|
237
|
+
return teams.get(sessionId) || null;
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Event normalization ────────────────────────────────────────────────
|
|
243
|
+
//
|
|
244
|
+
// Five event types from @openai/agents map to WMA contract action_types:
|
|
245
|
+
//
|
|
246
|
+
// agent_start → MESSAGE (with action_type override = 'agent_start')
|
|
247
|
+
// agent_end → MESSAGE (with action_type override = 'agent_end')
|
|
248
|
+
// agent_handoff → HANDOFF
|
|
249
|
+
// agent_tool_start → CUSTOM_TOOL_USE (the tool fires NEXT — capture
|
|
250
|
+
// input, no output yet)
|
|
251
|
+
// agent_tool_end → CUSTOM_TOOL_RESULT (capture output, link by
|
|
252
|
+
// tool_call_id to the start)
|
|
253
|
+
//
|
|
254
|
+
// Notes:
|
|
255
|
+
// - We pick CUSTOM_TOOL_USE over plain TOOL_USE: OpenAI Agents SDK
|
|
256
|
+
// tools are ALWAYS customer-defined (no provider-built-in shell
|
|
257
|
+
// equivalent at this surface). TOOL_USE stays reserved for provider-
|
|
258
|
+
// built-ins (Anthropic web_search, AgentCore Browser Tool, etc.).
|
|
259
|
+
// - agent_start / agent_end aren't in ACTION_TYPES today. We map them
|
|
260
|
+
// to MESSAGE so the contract validator stays green. The action_type
|
|
261
|
+
// override goes in the `output` envelope so consumers can still
|
|
262
|
+
// filter. A first-class agent lifecycle action_type may be proposed
|
|
263
|
+
// in v1.4.x.
|
|
264
|
+
|
|
265
|
+
export function normalizeAgentStart({ agent, turnInput, sessionId, teamId }) {
|
|
266
|
+
return Object.freeze({
|
|
267
|
+
id: `oai-evt-${randomUUID()}`,
|
|
268
|
+
provider: PROVIDER,
|
|
269
|
+
agent_id: agent?.name || fallbackAgentId('start'),
|
|
270
|
+
agent_name: agent?.name || null,
|
|
271
|
+
session_id: sessionId,
|
|
272
|
+
session_thread_id: sessionId,
|
|
273
|
+
action_type: ACTION_TYPES.MESSAGE,
|
|
274
|
+
timestamp: nowIso(),
|
|
275
|
+
status: STATUS_VALUES.OK,
|
|
276
|
+
tool_name: null,
|
|
277
|
+
model: agent?.model || null,
|
|
278
|
+
duration_ms: null,
|
|
279
|
+
parent_agent_id: null,
|
|
280
|
+
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
281
|
+
team_id: teamId,
|
|
282
|
+
input: turnInput ? { turn_input: turnInput } : null,
|
|
283
|
+
output: { kind: 'agent_start' },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function normalizeAgentEnd({ agent, output, sessionId, teamId }) {
|
|
288
|
+
return Object.freeze({
|
|
289
|
+
id: `oai-evt-${randomUUID()}`,
|
|
290
|
+
provider: PROVIDER,
|
|
291
|
+
agent_id: agent?.name || fallbackAgentId('end'),
|
|
292
|
+
agent_name: agent?.name || null,
|
|
293
|
+
session_id: sessionId,
|
|
294
|
+
session_thread_id: sessionId,
|
|
295
|
+
action_type: ACTION_TYPES.MESSAGE,
|
|
296
|
+
timestamp: nowIso(),
|
|
297
|
+
status: STATUS_VALUES.OK,
|
|
298
|
+
tool_name: null,
|
|
299
|
+
model: agent?.model || null,
|
|
300
|
+
duration_ms: null,
|
|
301
|
+
parent_agent_id: null,
|
|
302
|
+
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
303
|
+
team_id: teamId,
|
|
304
|
+
input: null,
|
|
305
|
+
output: { kind: 'agent_end', text: typeof output === 'string' ? output : null },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function normalizeAgentHandoff({ fromAgent, toAgent, sessionId, teamId }) {
|
|
310
|
+
return Object.freeze({
|
|
311
|
+
id: `oai-evt-${randomUUID()}`,
|
|
312
|
+
provider: PROVIDER,
|
|
313
|
+
agent_id: toAgent?.name || fallbackAgentId('handoff'),
|
|
314
|
+
agent_name: toAgent?.name || null,
|
|
315
|
+
session_id: sessionId,
|
|
316
|
+
session_thread_id: sessionId,
|
|
317
|
+
action_type: ACTION_TYPES.HANDOFF,
|
|
318
|
+
timestamp: nowIso(),
|
|
319
|
+
status: STATUS_VALUES.OK,
|
|
320
|
+
tool_name: null,
|
|
321
|
+
model: toAgent?.model || null,
|
|
322
|
+
duration_ms: null,
|
|
323
|
+
parent_agent_id: fromAgent?.name || null,
|
|
324
|
+
composition_pattern: COMPOSITION_PATTERNS.HIERARCHY,
|
|
325
|
+
team_id: teamId,
|
|
326
|
+
input: null,
|
|
327
|
+
output: {
|
|
328
|
+
kind: 'agent_handoff',
|
|
329
|
+
from: fromAgent?.name || null,
|
|
330
|
+
to: toAgent?.name || null,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function normalizeToolStart({ agent, tool, toolCall, sessionId, teamId, toolInputs }) {
|
|
336
|
+
const parsedArgs = safeParseToolArgs(toolCall?.arguments);
|
|
337
|
+
// v1.4 Codex #4 — per-tool argument aliases. If `toolInputs` is
|
|
338
|
+
// provided AND has an entry for this tool's name, apply the alias
|
|
339
|
+
// map via normalizeToolInput() from the anonymizer. The result
|
|
340
|
+
// populates canonical names (`url`, `query`, `command`, `path`,
|
|
341
|
+
// `file_path`) so the SignalsAggregator hashes them EXACTLY, not
|
|
342
|
+
// via the F-30 suffix heuristic (which is the opt-out safety net,
|
|
343
|
+
// not the precision path). Both layers coexist: explicit aliases
|
|
344
|
+
// win on declared fields; heuristic catches the long tail.
|
|
345
|
+
const aliases = toolInputs && tool?.name ? toolInputs[tool.name] : null;
|
|
346
|
+
const finalInput = aliases ? normalizeToolInput(parsedArgs, aliases) : parsedArgs;
|
|
347
|
+
return Object.freeze({
|
|
348
|
+
id: makeEventId(toolCall),
|
|
349
|
+
provider: PROVIDER,
|
|
350
|
+
agent_id: agent?.name || fallbackAgentId(tool?.name),
|
|
351
|
+
agent_name: agent?.name || null,
|
|
352
|
+
session_id: sessionId,
|
|
353
|
+
session_thread_id: sessionId,
|
|
354
|
+
action_type: ACTION_TYPES.CUSTOM_TOOL_USE,
|
|
355
|
+
timestamp: nowIso(),
|
|
356
|
+
status: STATUS_VALUES.OK,
|
|
357
|
+
tool_name: tool?.name || null,
|
|
358
|
+
model: agent?.model || null,
|
|
359
|
+
duration_ms: null,
|
|
360
|
+
parent_agent_id: null,
|
|
361
|
+
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
362
|
+
team_id: teamId,
|
|
363
|
+
input: finalInput,
|
|
364
|
+
output: null,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// v1.4 F-32 — cap result bytes before writing NDJSON. Verbose tools
|
|
369
|
+
// (HTML scrapers, web_fetch with full-page payloads, computer use
|
|
370
|
+
// screenshots base64'd) can return arbitrary megabytes. Without a cap
|
|
371
|
+
// we'd write a single ~MB-sized JSON line per call into the rotation
|
|
372
|
+
// file, fill the disk, and slow every subsequent NDJSON read.
|
|
373
|
+
function truncateResult(result, maxBytes = DEFAULT_MAX_RESULT_BYTES) {
|
|
374
|
+
if (typeof result === 'string') {
|
|
375
|
+
const byteLen = Buffer.byteLength(result, 'utf8');
|
|
376
|
+
if (byteLen > maxBytes) {
|
|
377
|
+
return {
|
|
378
|
+
text: result.slice(0, maxBytes) + TRUNCATION_SENTINEL,
|
|
379
|
+
_wmaTruncated: true,
|
|
380
|
+
_wmaOriginalBytes: byteLen,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return { text: result };
|
|
384
|
+
}
|
|
385
|
+
if (result == null) return { value: null };
|
|
386
|
+
// Object / array result: serialize, check size, truncate if needed.
|
|
387
|
+
try {
|
|
388
|
+
const serialized = JSON.stringify(result);
|
|
389
|
+
const byteLen = Buffer.byteLength(serialized, 'utf8');
|
|
390
|
+
if (byteLen > maxBytes) {
|
|
391
|
+
return {
|
|
392
|
+
value: { _wmaTruncated: true, _wmaOriginalBytes: byteLen },
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return { value: result };
|
|
396
|
+
} catch {
|
|
397
|
+
return { value: { _wmaTruncated: true, _wmaUnserializable: true } };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function normalizeToolEnd({ agent, tool, result, toolCall, sessionId, teamId }) {
|
|
402
|
+
return Object.freeze({
|
|
403
|
+
id: `${makeEventId(toolCall)}-end`,
|
|
404
|
+
provider: PROVIDER,
|
|
405
|
+
agent_id: agent?.name || fallbackAgentId(tool?.name),
|
|
406
|
+
agent_name: agent?.name || null,
|
|
407
|
+
session_id: sessionId,
|
|
408
|
+
session_thread_id: sessionId,
|
|
409
|
+
action_type: ACTION_TYPES.CUSTOM_TOOL_RESULT,
|
|
410
|
+
timestamp: nowIso(),
|
|
411
|
+
status: STATUS_VALUES.OK,
|
|
412
|
+
tool_name: tool?.name || null,
|
|
413
|
+
model: agent?.model || null,
|
|
414
|
+
duration_ms: null,
|
|
415
|
+
parent_agent_id: null,
|
|
416
|
+
composition_pattern: COMPOSITION_PATTERNS.SOLO,
|
|
417
|
+
team_id: teamId,
|
|
418
|
+
input: null,
|
|
419
|
+
output: truncateResult(result),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── Public API: Shield (Tool Input Guardrail) ──────────────────────────
|
|
424
|
+
//
|
|
425
|
+
// Returns a guardrail object SHAPE-COMPATIBLE with the @openai/agents
|
|
426
|
+
// `defineToolInputGuardrail()` output. Customer drops it into an Agent's
|
|
427
|
+
// `toolInputGuardrails: [...]` and the SDK runs it before every tool
|
|
428
|
+
// call.
|
|
429
|
+
//
|
|
430
|
+
// Options:
|
|
431
|
+
// policiesPath: string — local JSON policy file path
|
|
432
|
+
// ruleset: object — in-memory policy ruleset (overrides path)
|
|
433
|
+
// logDir: string — NDJSON log dir (default: WMA_LOG_DIR or
|
|
434
|
+
// ./watchmyagents-logs)
|
|
435
|
+
// sessionId: string — override session id (default: auto-minted)
|
|
436
|
+
// recentWindowSize:number — context tracker window (default: 20)
|
|
437
|
+
// failOpen: boolean — on Shield internal error, allow vs deny
|
|
438
|
+
// (default: false = fail-CLOSED)
|
|
439
|
+
// getTeamId: fn()→string|null — custom team resolver
|
|
440
|
+
// logger: Logger — inject an existing Logger (testing)
|
|
441
|
+
// decisionLogger: DecisionLogger — inject existing (testing)
|
|
442
|
+
// tracker: ContextTracker — inject existing (testing)
|
|
443
|
+
//
|
|
444
|
+
// All options are optional; defaults read from env. Lazy loading of the
|
|
445
|
+
// policy ruleset on first invocation if `policiesPath` is set.
|
|
446
|
+
|
|
447
|
+
export function wmaToolInputGuardrail(options = {}) {
|
|
448
|
+
const failOpen = options.failOpen === true ? true : DEFAULT_FAIL_OPEN;
|
|
449
|
+
const sessionId = options.sessionId || makeSessionId();
|
|
450
|
+
const logDir = options.logDir
|
|
451
|
+
|| readEnv('WMA_LOG_DIR', './watchmyagents-logs');
|
|
452
|
+
|
|
453
|
+
// Set up the shared shield state. Re-used across all tool calls
|
|
454
|
+
// through this guardrail instance.
|
|
455
|
+
let ruleset = options.ruleset || null;
|
|
456
|
+
const tracker = options.tracker
|
|
457
|
+
|| createContextTracker({ recentWindowSize: options.recentWindowSize ?? 20 });
|
|
458
|
+
// v1.3.1 F-29 (P1 Codex audit): provider must be passed explicitly so
|
|
459
|
+
// shield_decision NDJSON rows carry 'openai-agents' instead of being
|
|
460
|
+
// mis-attributed to 'anthropic-managed' (DecisionLogger's pre-v1.3.1
|
|
461
|
+
// hardcoded default). Fortress / Guardian forensic surfaces depend on
|
|
462
|
+
// this for correct multi-provider attribution.
|
|
463
|
+
const decisionLogger = options.decisionLogger
|
|
464
|
+
|| new DecisionLogger({ logDir, agentId: 'openai-agents', sessionId, provider: PROVIDER });
|
|
465
|
+
const logger = options.logger
|
|
466
|
+
|| new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: false });
|
|
467
|
+
|
|
468
|
+
// Team tracker — re-used for the entire run of this guardrail.
|
|
469
|
+
const envTeamId = teamIdFromEnv();
|
|
470
|
+
const teamTracker = createTeamTracker(envTeamId);
|
|
471
|
+
|
|
472
|
+
const getTeamId = typeof options.getTeamId === 'function'
|
|
473
|
+
? options.getTeamId
|
|
474
|
+
: () => teamTracker.bootstrap(sessionId);
|
|
475
|
+
|
|
476
|
+
// Lazily load the ruleset on first invocation if a path was given.
|
|
477
|
+
async function ensureRuleset() {
|
|
478
|
+
if (ruleset != null) return ruleset;
|
|
479
|
+
if (options.policiesPath) {
|
|
480
|
+
ruleset = await loadPolicies(options.policiesPath);
|
|
481
|
+
return ruleset;
|
|
482
|
+
}
|
|
483
|
+
// No ruleset, no path → default to "always allow". The customer
|
|
484
|
+
// probably means to use Fortress; we don't ship that wiring in
|
|
485
|
+
// v1.3.0 from this entry point. Log a warning once.
|
|
486
|
+
if (!ensureRuleset._warned) {
|
|
487
|
+
ensureRuleset._warned = true;
|
|
488
|
+
process.stderr.write(
|
|
489
|
+
'[wma/openai-agents] no policy ruleset configured — guardrail will allow all. ' +
|
|
490
|
+
'Pass { policiesPath } or { ruleset } to wmaToolInputGuardrail() to enforce.\n',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
ruleset = { policies: [], default: { action: 'allow' } };
|
|
494
|
+
return ruleset;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
type: 'tool_input',
|
|
499
|
+
name: 'watchmyagents-shield',
|
|
500
|
+
run: async (data) => {
|
|
501
|
+
// Defensive: data may be { context, agent, toolCall }.
|
|
502
|
+
const agent = data?.agent || null;
|
|
503
|
+
const toolCall = data?.toolCall || null;
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const rs = await ensureRuleset();
|
|
507
|
+
|
|
508
|
+
// 1. Normalize the about-to-fire tool_use event.
|
|
509
|
+
const event = normalizeToolStart({
|
|
510
|
+
agent,
|
|
511
|
+
tool: { name: toolCall?.name },
|
|
512
|
+
toolCall,
|
|
513
|
+
sessionId,
|
|
514
|
+
teamId: getTeamId(),
|
|
515
|
+
// v1.4 Codex #4 — per-tool alias map (option `toolInputs`).
|
|
516
|
+
// Threaded through so the anonymizer hashes by canonical name
|
|
517
|
+
// for tools the customer explicitly mapped.
|
|
518
|
+
toolInputs: options.toolInputs,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// 2. Compute policy context BEFORE evaluation so recent_error_rate
|
|
522
|
+
// and friends reflect prior history.
|
|
523
|
+
const policyCtx = tracker.compute(event);
|
|
524
|
+
|
|
525
|
+
// 3. Evaluate Shield.
|
|
526
|
+
const t0 = Date.now();
|
|
527
|
+
const result = evaluate(event, rs, policyCtx);
|
|
528
|
+
const decidedInMs = Date.now() - t0;
|
|
529
|
+
|
|
530
|
+
// 4. Audit-grade log (chain-signed).
|
|
531
|
+
await decisionLogger.record({
|
|
532
|
+
sourceEvent: { id: event.id, type: event.action_type, tool_name: event.tool_name, input: event.input },
|
|
533
|
+
decision: result.decision,
|
|
534
|
+
ruleId: result.rule_id,
|
|
535
|
+
ruleName: result.rule_name,
|
|
536
|
+
message: result.message,
|
|
537
|
+
decidedInMs,
|
|
538
|
+
mode: result.mode,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// 5. Watch log (the event itself, separate from the decision).
|
|
542
|
+
await logger.write(event);
|
|
543
|
+
|
|
544
|
+
// 6. Record outcome into the context tracker for the next event.
|
|
545
|
+
// On a Shield block, the tool won't run — that's NOT a tool
|
|
546
|
+
// error from the agent's perspective. Use the default
|
|
547
|
+
// heuristic which won't mark allow/deny as errors.
|
|
548
|
+
tracker.record(event);
|
|
549
|
+
|
|
550
|
+
// 7. Translate Shield decision into OpenAI guardrail behavior.
|
|
551
|
+
// Shadow mode never blocks — log + allow.
|
|
552
|
+
if (result.mode === 'shadow') {
|
|
553
|
+
return ToolGuardrailFunctionOutputFactory.allow({
|
|
554
|
+
wma: { rule_id: result.rule_id, mode: 'shadow', shadow_decision: result.decision },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
if (result.decision === 'deny') {
|
|
558
|
+
return ToolGuardrailFunctionOutputFactory.rejectContent(
|
|
559
|
+
result.message || `Blocked by ${result.rule_name || 'WMA Shield'}`,
|
|
560
|
+
{ wma: { rule_id: result.rule_id, rule_name: result.rule_name } },
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (result.decision === 'interrupt') {
|
|
564
|
+
return ToolGuardrailFunctionOutputFactory.throwException({
|
|
565
|
+
wma: { rule_id: result.rule_id, rule_name: result.rule_name, message: result.message },
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return ToolGuardrailFunctionOutputFactory.allow({
|
|
569
|
+
wma: { rule_id: null, rule_name: '(default-allow)' },
|
|
570
|
+
});
|
|
571
|
+
} catch (err) {
|
|
572
|
+
// Shield internal failure (policy file unreadable, disk full on
|
|
573
|
+
// log write, etc.). Default fail-CLOSED unless customer opted
|
|
574
|
+
// into fail-OPEN.
|
|
575
|
+
//
|
|
576
|
+
// v1.4 F-31 (P2 Codex audit on v1.3.0): the agent must NOT see
|
|
577
|
+
// the raw err.message — it can carry local paths, policy file
|
|
578
|
+
// names, disk-mount strings, permission codes, or even fragments
|
|
579
|
+
// of the policy itself that should never reach a model context.
|
|
580
|
+
// We mint a short reference code, log the full error locally to
|
|
581
|
+
// stderr keyed by that code, and return a generic message that
|
|
582
|
+
// operators can correlate against the local log without exposing
|
|
583
|
+
// anything actionable to the model. The outputInfo (which is NOT
|
|
584
|
+
// surfaced to the model — it stays in the application's run
|
|
585
|
+
// metadata) keeps the err.message for SDK-side tracing.
|
|
586
|
+
const errorRef = makeErrorRef();
|
|
587
|
+
process.stderr.write(
|
|
588
|
+
`[wma/openai-agents] [${errorRef}] guardrail error: ${err.message}\n`,
|
|
589
|
+
);
|
|
590
|
+
if (failOpen) {
|
|
591
|
+
return ToolGuardrailFunctionOutputFactory.allow({
|
|
592
|
+
wma: {
|
|
593
|
+
errorRef,
|
|
594
|
+
error: err.message,
|
|
595
|
+
failOpenApplied: true,
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return ToolGuardrailFunctionOutputFactory.rejectContent(
|
|
600
|
+
`WMA Shield internal error (fail-closed). Ref: ${errorRef}`,
|
|
601
|
+
{
|
|
602
|
+
wma: {
|
|
603
|
+
errorRef,
|
|
604
|
+
error: err.message,
|
|
605
|
+
failOpenApplied: false,
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── Public API: Watch (RunHooks listener attach) ───────────────────────
|
|
615
|
+
//
|
|
616
|
+
// Attaches WMA listeners to a Runner (or any EventEmitter-shaped object
|
|
617
|
+
// with `.on(event, listener)`). Captures all 5 lifecycle events,
|
|
618
|
+
// normalizes each, and writes to local NDJSON. Returns an unsubscribe
|
|
619
|
+
// function the customer can call on cleanup.
|
|
620
|
+
//
|
|
621
|
+
// Why a separate function from the guardrail:
|
|
622
|
+
// The guardrail is OPTIONAL (customer might want Watch-only — pure
|
|
623
|
+
// observability with no enforcement). The Watch attach is the
|
|
624
|
+
// complementary opt-in. Both are independent.
|
|
625
|
+
|
|
626
|
+
export function attachWmaWatch(runner, options = {}) {
|
|
627
|
+
if (!runner || typeof runner.on !== 'function') {
|
|
628
|
+
throw new TypeError(
|
|
629
|
+
'attachWmaWatch: runner must expose an .on(event, listener) method ' +
|
|
630
|
+
'(the @openai/agents Runner EventEmitter, or a compatible shim).',
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const sessionId = options.sessionId || makeSessionId();
|
|
635
|
+
const logDir = options.logDir
|
|
636
|
+
|| readEnv('WMA_LOG_DIR', './watchmyagents-logs');
|
|
637
|
+
const logger = options.logger
|
|
638
|
+
|| new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: true });
|
|
639
|
+
|
|
640
|
+
const envTeamId = teamIdFromEnv();
|
|
641
|
+
const teamTracker = options.teamTracker || createTeamTracker(envTeamId);
|
|
642
|
+
|
|
643
|
+
// Per-event handlers. Each one normalizes then writes. We wrap each in
|
|
644
|
+
// try/catch — Watch must NEVER throw inside the customer's Runner
|
|
645
|
+
// event loop. Watch is observation; observation must be best-effort.
|
|
646
|
+
const handlers = {
|
|
647
|
+
agent_start: async (context, agent, turnInput) => {
|
|
648
|
+
try {
|
|
649
|
+
const teamId = teamTracker.bootstrap(sessionId);
|
|
650
|
+
const event = normalizeAgentStart({ agent, turnInput, sessionId, teamId });
|
|
651
|
+
await logger.write(event);
|
|
652
|
+
} catch (e) {
|
|
653
|
+
process.stderr.write(`[wma/openai-agents] watch agent_start error: ${e.message}\n`);
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
agent_end: async (context, agent, output) => {
|
|
657
|
+
try {
|
|
658
|
+
const teamId = teamTracker.resolve(sessionId);
|
|
659
|
+
const event = normalizeAgentEnd({ agent, output, sessionId, teamId });
|
|
660
|
+
await logger.write(event);
|
|
661
|
+
} catch (e) {
|
|
662
|
+
process.stderr.write(`[wma/openai-agents] watch agent_end error: ${e.message}\n`);
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
agent_handoff: async (context, fromAgent, toAgent) => {
|
|
666
|
+
try {
|
|
667
|
+
const teamId = teamTracker.recordHandoff(fromAgent, toAgent, sessionId)
|
|
668
|
+
|| teamTracker.bootstrap(sessionId);
|
|
669
|
+
const event = normalizeAgentHandoff({ fromAgent, toAgent, sessionId, teamId });
|
|
670
|
+
await logger.write(event);
|
|
671
|
+
} catch (e) {
|
|
672
|
+
process.stderr.write(`[wma/openai-agents] watch agent_handoff error: ${e.message}\n`);
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
agent_tool_start: async (context, agent, tool, details) => {
|
|
676
|
+
try {
|
|
677
|
+
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
678
|
+
const event = normalizeToolStart({
|
|
679
|
+
agent, tool, toolCall: details?.toolCall, sessionId, teamId,
|
|
680
|
+
toolInputs: options.toolInputs, // v1.4 Codex #4
|
|
681
|
+
});
|
|
682
|
+
await logger.write(event);
|
|
683
|
+
} catch (e) {
|
|
684
|
+
process.stderr.write(`[wma/openai-agents] watch agent_tool_start error: ${e.message}\n`);
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
agent_tool_end: async (context, agent, tool, result, details) => {
|
|
688
|
+
try {
|
|
689
|
+
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
690
|
+
const event = normalizeToolEnd({
|
|
691
|
+
agent, tool, result, toolCall: details?.toolCall, sessionId, teamId,
|
|
692
|
+
});
|
|
693
|
+
await logger.write(event);
|
|
694
|
+
} catch (e) {
|
|
695
|
+
process.stderr.write(`[wma/openai-agents] watch agent_tool_end error: ${e.message}\n`);
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
for (const [evt, fn] of Object.entries(handlers)) {
|
|
701
|
+
runner.on(evt, fn);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Unsubscribe helper. If `runner.off` exists (standard EventEmitter),
|
|
705
|
+
// detach. Otherwise no-op. Customer should call this on shutdown.
|
|
706
|
+
return function detachWmaWatch() {
|
|
707
|
+
if (typeof runner.off !== 'function') return;
|
|
708
|
+
for (const [evt, fn] of Object.entries(handlers)) {
|
|
709
|
+
try { runner.off(evt, fn); }
|
|
710
|
+
catch { /* listener wasn't attached or off() not supported */ }
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ── Public API: Watch via AgentHooks (when customer uses run() helper) ─
|
|
716
|
+
//
|
|
717
|
+
// The @openai/agents SDK exposes TWO event-emitter surfaces:
|
|
718
|
+
// - RunHooks : `runner.on(event, listener)` — fires when customer
|
|
719
|
+
// explicitly creates a Runner via `new Runner()`.
|
|
720
|
+
// Listener args INCLUDE the agent.
|
|
721
|
+
// - AgentHooks: `agent.on(event, listener)` — fires when customer
|
|
722
|
+
// uses the convenience `run(agent, ...)` function
|
|
723
|
+
// (without an explicit Runner). The agent is implicit
|
|
724
|
+
// (it's the one we registered listeners on), so the
|
|
725
|
+
// listener args do NOT include it (except agent_start).
|
|
726
|
+
//
|
|
727
|
+
// Real-world capture (June 2026) shows AgentHooks signatures:
|
|
728
|
+
// agent_start [context, agent, turnInput?]
|
|
729
|
+
// agent_end [context, output] ← no agent
|
|
730
|
+
// agent_handoff [context, toAgent] ← only the "to" side
|
|
731
|
+
// agent_tool_start [context, tool, details] ← no agent
|
|
732
|
+
// agent_tool_end [context, tool, result, details] ← no agent
|
|
733
|
+
//
|
|
734
|
+
// `attachWmaWatchToAgent(agent, options)` mirrors `attachWmaWatch`
|
|
735
|
+
// for the AgentHooks pattern. The agent is captured via closure so
|
|
736
|
+
// our normalizers still receive an agent_id even for events that
|
|
737
|
+
// don't pass the agent in their args.
|
|
738
|
+
//
|
|
739
|
+
// Returns a `detach()` function symmetric to attachWmaWatch.
|
|
740
|
+
|
|
741
|
+
export function attachWmaWatchToAgent(agent, options = {}) {
|
|
742
|
+
if (!agent || typeof agent.on !== 'function') {
|
|
743
|
+
throw new TypeError(
|
|
744
|
+
'attachWmaWatchToAgent: agent must expose an .on(event, listener) method ' +
|
|
745
|
+
'(the @openai/agents Agent EventEmitter, or a compatible shim).',
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const sessionId = options.sessionId || makeSessionId();
|
|
750
|
+
const logDir = options.logDir
|
|
751
|
+
|| readEnv('WMA_LOG_DIR', './watchmyagents-logs');
|
|
752
|
+
const logger = options.logger
|
|
753
|
+
|| new Logger({ logDir, agentId: 'openai-agents', sessionId, silent: true, bestEffort: true });
|
|
754
|
+
|
|
755
|
+
const envTeamId = teamIdFromEnv();
|
|
756
|
+
const teamTracker = options.teamTracker || createTeamTracker(envTeamId);
|
|
757
|
+
|
|
758
|
+
// Same try/catch wrapping discipline as attachWmaWatch: Watch must
|
|
759
|
+
// NEVER throw inside the customer's agent loop. Handlers below absorb
|
|
760
|
+
// exceptions and write them to stderr instead of propagating.
|
|
761
|
+
const handlers = {
|
|
762
|
+
agent_start: async (context, eventAgent, turnInput) => {
|
|
763
|
+
try {
|
|
764
|
+
const teamId = teamTracker.bootstrap(sessionId);
|
|
765
|
+
const event = normalizeAgentStart({
|
|
766
|
+
agent: eventAgent || agent,
|
|
767
|
+
turnInput, sessionId, teamId,
|
|
768
|
+
});
|
|
769
|
+
await logger.write(event);
|
|
770
|
+
} catch (e) {
|
|
771
|
+
process.stderr.write(`[wma/openai-agents] watch agent_start error: ${e.message}\n`);
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
// AgentHooks agent_end does NOT pass the agent — captured via closure.
|
|
775
|
+
agent_end: async (context, output) => {
|
|
776
|
+
try {
|
|
777
|
+
const teamId = teamTracker.resolve(sessionId);
|
|
778
|
+
const event = normalizeAgentEnd({ agent, output, sessionId, teamId });
|
|
779
|
+
await logger.write(event);
|
|
780
|
+
} catch (e) {
|
|
781
|
+
process.stderr.write(`[wma/openai-agents] watch agent_end error: ${e.message}\n`);
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
// AgentHooks agent_handoff passes only the "to" agent. The "from"
|
|
785
|
+
// is the agent we registered on (this listener is fired right
|
|
786
|
+
// before control passes away from it).
|
|
787
|
+
agent_handoff: async (context, toAgent) => {
|
|
788
|
+
try {
|
|
789
|
+
const teamId = teamTracker.recordHandoff(agent, toAgent, sessionId)
|
|
790
|
+
|| teamTracker.bootstrap(sessionId);
|
|
791
|
+
const event = normalizeAgentHandoff({
|
|
792
|
+
fromAgent: agent, toAgent, sessionId, teamId,
|
|
793
|
+
});
|
|
794
|
+
await logger.write(event);
|
|
795
|
+
} catch (e) {
|
|
796
|
+
process.stderr.write(`[wma/openai-agents] watch agent_handoff error: ${e.message}\n`);
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
// AgentHooks tool events do NOT pass the agent — captured via closure.
|
|
800
|
+
agent_tool_start: async (context, tool, details) => {
|
|
801
|
+
try {
|
|
802
|
+
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
803
|
+
const event = normalizeToolStart({
|
|
804
|
+
agent, tool, toolCall: details?.toolCall, sessionId, teamId,
|
|
805
|
+
toolInputs: options.toolInputs, // v1.4 Codex #4
|
|
806
|
+
});
|
|
807
|
+
await logger.write(event);
|
|
808
|
+
} catch (e) {
|
|
809
|
+
process.stderr.write(`[wma/openai-agents] watch agent_tool_start error: ${e.message}\n`);
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
agent_tool_end: async (context, tool, result, details) => {
|
|
813
|
+
try {
|
|
814
|
+
const teamId = teamTracker.resolve(sessionId) || teamTracker.bootstrap(sessionId);
|
|
815
|
+
const event = normalizeToolEnd({
|
|
816
|
+
agent, tool, result, toolCall: details?.toolCall, sessionId, teamId,
|
|
817
|
+
});
|
|
818
|
+
await logger.write(event);
|
|
819
|
+
} catch (e) {
|
|
820
|
+
process.stderr.write(`[wma/openai-agents] watch agent_tool_end error: ${e.message}\n`);
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
for (const [evt, fn] of Object.entries(handlers)) {
|
|
826
|
+
agent.on(evt, fn);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return function detachWmaWatchFromAgent() {
|
|
830
|
+
if (typeof agent.off !== 'function') return;
|
|
831
|
+
for (const [evt, fn] of Object.entries(handlers)) {
|
|
832
|
+
try { agent.off(evt, fn); }
|
|
833
|
+
catch { /* listener wasn't attached or off() not supported */ }
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ── Enforcement metadata (for adapter registry / introspection) ───────
|
|
839
|
+
|
|
840
|
+
export const adapterMeta = Object.freeze({
|
|
841
|
+
provider: PROVIDER,
|
|
842
|
+
displayName: 'OpenAI Agents SDK',
|
|
843
|
+
enforcement: ENFORCEMENT_MODES.SYNC_CONFIRM,
|
|
844
|
+
compositionDefault: COMPOSITION_PATTERNS.SOLO,
|
|
845
|
+
customerInstrumented: true, // not auto-discovery
|
|
846
|
+
peerPackage: '@openai/agents',
|
|
847
|
+
// v1.4 F-33 — aligned with package.json#peerDependencies range
|
|
848
|
+
// `^0.2.0`. Real fixtures captured against 0.2.x verified the
|
|
849
|
+
// lifecycle event signatures in v1.3.0; minPeerVersion below that is
|
|
850
|
+
// unsupported and may show drift in args layout (the AgentHooks vs
|
|
851
|
+
// RunHooks gap that motivated the v1.3.0 finalize commit).
|
|
852
|
+
minPeerVersion: '0.2.0',
|
|
853
|
+
capabilities: Object.freeze({
|
|
854
|
+
watch: true,
|
|
855
|
+
shield: true,
|
|
856
|
+
preToolDeny: true, // via ToolInputGuardrail
|
|
857
|
+
postToolFilter: false, // ToolOutputGuardrail later
|
|
858
|
+
teamIdAutoDetect: true, // via agent_handoff
|
|
859
|
+
streamingSupported: 'verify-day-2', // verify in smoke test
|
|
860
|
+
}),
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// Internal-only — exported for tests. The factory mirrors @openai/agents'
|
|
864
|
+
// public symbol; tests can assert against it.
|
|
865
|
+
export const __testing__ = Object.freeze({
|
|
866
|
+
ToolGuardrailFunctionOutputFactory,
|
|
867
|
+
safeParseToolArgs,
|
|
868
|
+
makeSessionId,
|
|
869
|
+
makeEventId,
|
|
870
|
+
fallbackAgentId,
|
|
871
|
+
createTeamTracker,
|
|
872
|
+
});
|