watchmyagents 1.2.1 → 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.
@@ -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
+ });