@responsibleai/agent-hooks 0.1.0-alpha.2

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,124 @@
1
+ "use strict";
2
+ // Copyright (c) Microsoft Corporation.
3
+ // Licensed under the MIT License.
4
+ /**
5
+ * Reference in-memory agent + harness.
6
+ *
7
+ * The simplest possible conformant agent loop; exists so the
8
+ * CTK can self-test without a real framework. Port of
9
+ * `sdk/python/python/agent_hooks/ctk/reference.py`.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.ReferenceHarness = void 0;
13
+ const node_crypto_1 = require("node:crypto");
14
+ const index_1 = require("../index");
15
+ const builder_1 = require("../builder");
16
+ const emitter_1 = require("../emitter");
17
+ class ReferenceHarness {
18
+ name = "reference-agent";
19
+ capabilities = new Set(["model_calls", "tool_calls"]);
20
+ scenario;
21
+ emitter;
22
+ builder;
23
+ toolLog = [];
24
+ setup(scenario, interceptors, resolver, mode, composition, identityProvider) {
25
+ this.scenario = scenario;
26
+ this.toolLog = [];
27
+ const em = new emitter_1.InterceptionEmitter(mode, resolver);
28
+ em.setComposition(composition);
29
+ em.setIdentityProvider(identityProvider);
30
+ for (const i of interceptors)
31
+ em.register(i);
32
+ this.emitter = em;
33
+ this.builder = new builder_1.AgentContextBuilder({
34
+ agentId: "ref-agent",
35
+ framework: "reference-agent",
36
+ sessionId: (0, node_crypto_1.randomUUID)(),
37
+ });
38
+ }
39
+ async run() {
40
+ const s = this.scenario;
41
+ const em = this.emitter;
42
+ const b = this.builder;
43
+ let outcome = "completed";
44
+ let final = null;
45
+ const tools = new Map((s.tools ?? []).map((t) => [t.name, t]));
46
+ const invokeTool = (name, args) => {
47
+ const spec = tools.get(name);
48
+ if (!spec)
49
+ throw new Error(`tool ${name} not in scenario`);
50
+ for (const bh of spec.behavior) {
51
+ if (!bh.when_args || JSON.stringify(bh.when_args) === JSON.stringify(args)) {
52
+ return { value: bh.return, is_error: bh.is_error ?? false };
53
+ }
54
+ }
55
+ throw new Error(`tool ${name} invoked with ${JSON.stringify(args)}: no matching behavior`);
56
+ };
57
+ try {
58
+ await em.emit(b.agentStartup([...tools.keys()].sort()));
59
+ await em.emit(b.input(s.input.content, s.input.role));
60
+ let messages = [
61
+ { role: s.input.role, content: s.input.content },
62
+ ];
63
+ for (const step of s.model_script ?? []) {
64
+ const resp = step.respond;
65
+ const preCtx = b.preModelCall("mock", [...messages]);
66
+ await em.emit(preCtx);
67
+ messages = preCtx.messages;
68
+ await em.emit(b.postModelCall("mock", resp.content, resp.tool_calls, resp.finish_reason));
69
+ if (resp.tool_calls.length > 0) {
70
+ for (const tc of resp.tool_calls) {
71
+ try {
72
+ const preTc = b.preToolCall(tc.id, tc.name, { ...tc.args });
73
+ await em.emit(preTc);
74
+ const args = preTc.tool_call.args;
75
+ const { value, is_error } = invokeTool(tc.name, args);
76
+ this.toolLog.push({ name: tc.name, args: { ...args } });
77
+ await em.emit(b.postToolCall(tc.id, tc.name, { ...args }, value, is_error));
78
+ messages.push({ role: "tool", content: value });
79
+ }
80
+ catch (e) {
81
+ if (e instanceof index_1.InterceptionBlocked) {
82
+ messages.push({ role: "tool", content: `blocked: ${e.result.verdict.reason}` });
83
+ }
84
+ else {
85
+ throw e;
86
+ }
87
+ }
88
+ }
89
+ messages.push({ role: "assistant", content: resp.content ?? "" });
90
+ }
91
+ else {
92
+ final = resp.content;
93
+ break;
94
+ }
95
+ }
96
+ if (final !== null) {
97
+ const outCtx = b.output(final);
98
+ await em.emit(outCtx);
99
+ final = outCtx.output.content;
100
+ }
101
+ }
102
+ catch (e) {
103
+ if (e instanceof index_1.InterceptionBlocked) {
104
+ outcome = "blocked";
105
+ final = null;
106
+ }
107
+ else {
108
+ throw e;
109
+ }
110
+ }
111
+ await em.emitUnchecked(b.agentShutdown(outcome === "completed" ? "completed" : "error"));
112
+ return {
113
+ outcome,
114
+ final_output: final,
115
+ tool_invocations: this.toolLog,
116
+ identities: em.records.map((r) => [r.input_identity, r.enforced_identity]),
117
+ records: em.records,
118
+ };
119
+ }
120
+ teardown() {
121
+ // no-op
122
+ }
123
+ }
124
+ exports.ReferenceHarness = ReferenceHarness;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * CTK runner: load vectors, drive a harness, assert `expect`.
3
+ *
4
+ * The assertion engine, capability skip check, and scripted
5
+ * interceptor/resolver evaluation live in the Rust core (native.ctk*).
6
+ * This module keeps only vector globbing, the recording wrapper, and
7
+ * the orchestration loop that calls the native `Harness`.
8
+ */
9
+ import { JsonValue } from "../index";
10
+ import type { Harness } from "./index";
11
+ /** TODO(stage-4): vectors still authored in the pre-P-003 five-verdict
12
+ * wire vocabulary (`warn`, `escalate`, `approval_resolver_missing`).
13
+ * Stage 4 rewrites them to the three-verdict shapes (§5.1); until then
14
+ * their scripted verdicts fail the §5 gate by design (fail closed) and
15
+ * no longer exercise the seam/warning semantics they were written for,
16
+ * so they are skipped here. */
17
+ export interface VectorResult {
18
+ id: string;
19
+ title: string;
20
+ status: "pass" | "fail" | "skip";
21
+ detail: string;
22
+ failures: string[];
23
+ }
24
+ export declare function loadVectors(dir: string): JsonValue[];
25
+ export declare function runVector(harness: Harness, vector: JsonValue): Promise<VectorResult>;
26
+ export declare function runVectors(harnessFactory: () => Harness, vectors: JsonValue[]): Promise<VectorResult[]>;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ // Copyright (c) Microsoft Corporation.
3
+ // Licensed under the MIT License.
4
+ /**
5
+ * CTK runner: load vectors, drive a harness, assert `expect`.
6
+ *
7
+ * The assertion engine, capability skip check, and scripted
8
+ * interceptor/resolver evaluation live in the Rust core (native.ctk*).
9
+ * This module keeps only vector globbing, the recording wrapper, and
10
+ * the orchestration loop that calls the native `Harness`.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.loadVectors = loadVectors;
14
+ exports.runVector = runVector;
15
+ exports.runVectors = runVectors;
16
+ const node_fs_1 = require("node:fs");
17
+ const node_path_1 = require("node:path");
18
+ const index_1 = require("../index");
19
+ const native_1 = require("../native");
20
+ function loadVectors(dir) {
21
+ const vectors = (0, node_fs_1.readdirSync)(dir)
22
+ .filter((f) => /^AH-CTK-.*\.json$/.test(f))
23
+ .sort()
24
+ .map((f) => JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, f), "utf8")));
25
+ if (vectors.length === 0) {
26
+ // A runner fed zero vectors reports 100% pass — a false
27
+ // conformance signal (§13.2). Fail loudly instead.
28
+ throw new Error(`no AH-CTK-*.json vectors found in ${dir}`);
29
+ }
30
+ return vectors;
31
+ }
32
+ /** Replays one `interceptor_script` rule list via the Rust core. */
33
+ class ScriptedInterceptor {
34
+ rulesJson;
35
+ constructor(rules) {
36
+ this.rulesJson = JSON.stringify(rules);
37
+ }
38
+ intercept(ctx) {
39
+ const w = JSON.parse(native_1.native.ctkScriptedIntercept(this.rulesJson, JSON.stringify(ctx)));
40
+ if (w !== null && typeof w === "object" && "__ctk_fault__" in w) {
41
+ // NOW-10 fault injection: exercise §6.3 interceptor_failed.
42
+ throw new Error("ctk scripted fault: raise");
43
+ }
44
+ return w;
45
+ }
46
+ }
47
+ /** Wraps the scripted interceptor and records every ctx passed. */
48
+ class RecordingInterceptor extends ScriptedInterceptor {
49
+ recorded = [];
50
+ intercept(ctx) {
51
+ this.recorded.push(JSON.parse(JSON.stringify(ctx)));
52
+ return super.intercept(ctx);
53
+ }
54
+ }
55
+ class ScriptedResolver {
56
+ rulesJson;
57
+ constructor(rules) {
58
+ this.rulesJson = JSON.stringify(rules);
59
+ }
60
+ resolve(req) {
61
+ // §10.1: identity may be null (null provider). The scripted engine
62
+ // works in strings; "" round-trips to null below.
63
+ const requestIdentity = req.context_identity ?? "";
64
+ const r = JSON.parse(native_1.native.ctkScriptedResolve(this.rulesJson, JSON.stringify(req.context), requestIdentity));
65
+ if (r !== null && typeof r === "object" && "__ctk_fault__" in r) {
66
+ // NOW-10 fault injection: exercise §9 approval_resolver_failed.
67
+ throw new Error("ctk scripted fault: raise");
68
+ }
69
+ if (r.context_identity === "" && req.context_identity === null) {
70
+ r.context_identity = null;
71
+ }
72
+ return r;
73
+ }
74
+ }
75
+ function runRecordToWire(rr) {
76
+ return JSON.stringify({
77
+ outcome: rr.outcome,
78
+ final_output: rr.final_output ?? null,
79
+ tool_invocations: rr.tool_invocations,
80
+ error: rr.error ?? null,
81
+ identities: rr.identities.map(([i, e]) => ({ input_identity: i, enforced_identity: e })),
82
+ records: rr.records,
83
+ });
84
+ }
85
+ async function runVector(harness, vector) {
86
+ const v = vector;
87
+ const vectorJson = JSON.stringify(vector);
88
+ const capsJson = JSON.stringify([...harness.capabilities].sort());
89
+ const skip = JSON.parse(native_1.native.ctkShouldSkip(vectorJson, capsJson));
90
+ if (skip !== null) {
91
+ return {
92
+ id: v.id,
93
+ title: v.title,
94
+ status: "skip",
95
+ detail: skip,
96
+ failures: [],
97
+ };
98
+ }
99
+ // Multi-interceptor vectors (§7.1 fold-through) use interceptor_scripts;
100
+ // single-interceptor vectors use interceptor_script. Only the FIRST
101
+ // interceptor records: expect.interceptions describes each emission as
102
+ // the first-registered interceptor saw it. An empty interceptor_scripts
103
+ // registers zero interceptors (§7 fail-closed vector).
104
+ const scripts = v.interceptor_scripts ?? [v.interceptor_script];
105
+ const first = scripts.length > 0 ? new RecordingInterceptor(scripts[0]) : null;
106
+ const interceptors = first ? [first] : [];
107
+ for (const s of scripts.slice(1))
108
+ interceptors.push(new ScriptedInterceptor(s));
109
+ const approval = v.approval_script;
110
+ // NB: [] is truthy in JS — an empty approval_script registers NO
111
+ // resolver (matches the Rust/Python runners; exercised by AH-CTK-032).
112
+ const resolver = approval && approval.length > 0 ? new ScriptedResolver(approval) : null;
113
+ const mode = (v.mode ?? "enforce");
114
+ // §13.2: composition vectors carry the profile/knobs they apply to;
115
+ // absent means the pre-P-003 default (`sequential/first_deny, stop`).
116
+ const composition = v.composition ?? index_1.Composition.default();
117
+ // §10.1: absent → the default provider; explicit null → unbound.
118
+ const identityProvider = 'identity_provider' in v ? v.identity_provider : 'jcs-sha256';
119
+ harness.setup(v.scenario, interceptors, resolver, mode, composition, identityProvider);
120
+ let rr;
121
+ try {
122
+ rr = await harness.run();
123
+ }
124
+ catch (e) {
125
+ return {
126
+ id: v.id,
127
+ title: v.title,
128
+ status: "fail",
129
+ detail: "",
130
+ failures: [`harness.run threw: ${e}`],
131
+ };
132
+ }
133
+ finally {
134
+ harness.teardown();
135
+ }
136
+ return JSON.parse(native_1.native.ctkAssert(vectorJson, JSON.stringify(first?.recorded ?? []), runRecordToWire(rr)));
137
+ }
138
+ async function runVectors(harnessFactory, vectors) {
139
+ const out = [];
140
+ for (const v of vectors) {
141
+ out.push(await runVector(harnessFactory(), v));
142
+ }
143
+ return out;
144
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Host-side emitter: dispatch context → interceptors → composition →
3
+ * combined verdict → record (§6–§10).
4
+ *
5
+ * Per-language orchestrator over the Rust core:
6
+ *
7
+ * - Interceptor dispatch (§7) and approval-seam resolution (§9) stay here
8
+ * because they call back into user JS code.
9
+ * - Verdict validation (§5), transform application (§5.2, §7.4),
10
+ * severity-max aggregation (§7.3, via `composeAggregate`), identity
11
+ * computation (§10), and record building (§10.3) delegate to the Rust
12
+ * core so behaviour is byte-identical across SDKs.
13
+ *
14
+ * Composition is host configuration (§7.1): the profile is set on the
15
+ * emitter (default `sequential/first_deny, on_approval: stop`) and
16
+ * recorded on every emission. "Parallel" profiles are implemented with
17
+ * serial dispatch over isolated snapshots — §7.2: parallel names
18
+ * isolation semantics, not scheduling.
19
+ *
20
+ * Fail-closed defaults: an `enforce`-mode emission with zero registered
21
+ * interceptors yields `deny host_error:no_interceptor` (§7), and
22
+ * {@link InterceptionEmitter.emit} **throws** {@link InterceptionBlocked}
23
+ * on any block — the ignorable-result variant is the explicitly named
24
+ * {@link InterceptionEmitter.emitUnchecked}.
25
+ *
26
+ * Concurrency (§12.2): emissions for different tool calls may interleave
27
+ * on the event loop; sequence assignment and record append are atomic on
28
+ * a single JS thread. Sharing one emitter across workers is unsupported.
29
+ */
30
+ import { AgentContext, ApprovalResolver, CompositionConfig, EnforcementMode, IdentityProvider, InterceptionRecord, Interceptor } from "./index";
31
+ /** §7 RECOMMENDED interceptor/resolver timeout (milliseconds). */
32
+ export declare const DEFAULT_TIMEOUT_MS = 5000;
33
+ export declare class InterceptionEmitter {
34
+ private readonly mode;
35
+ private readonly resolver;
36
+ private readonly timeoutMs;
37
+ private readonly interceptors;
38
+ private readonly _records;
39
+ private composition;
40
+ private identity;
41
+ /**
42
+ * `timeoutMs` bounds each interceptor `intercept()` and resolver
43
+ * `resolve()` call (§7, RECOMMENDED default 5000 ms); breach fails
44
+ * closed with `host_error:interceptor_timeout` (interceptor) or
45
+ * `host_error:approval_resolver_failed` (resolver). Only async work
46
+ * can be preempted — a synchronous interceptor that blocks the event
47
+ * loop cannot be interrupted. `timeoutMs: null` disables enforcement.
48
+ */
49
+ constructor(mode?: EnforcementMode, resolver?: ApprovalResolver | null, timeoutMs?: number | null);
50
+ /** Race `fn`'s result against the §7 timeout. */
51
+ private withTimeout;
52
+ get records(): readonly InterceptionRecord[];
53
+ register(interceptor: Interceptor): this;
54
+ /** Declare the composition profile for subsequent emissions (§7.1). */
55
+ setComposition(composition: CompositionConfig): this;
56
+ /** Declare the identity provider (§10.1). Default `"jcs-sha256"`. */
57
+ setIdentityProvider(provider: IdentityProvider): this;
58
+ /** Run the emission and **throw** {@link InterceptionBlocked} if the
59
+ * guarded action must not proceed (§6). Primary entry point. */
60
+ emit(ctx: AgentContext): Promise<InterceptionRecord>;
61
+ /** Run the emission and return the record without throwing. The
62
+ * caller MUST inspect `proceeds` and halt the guarded action itself;
63
+ * prefer {@link emit}. */
64
+ emitUnchecked(ctx: AgentContext): Promise<InterceptionRecord>;
65
+ /** Profile dispatch (§7.4–§7.5). Returns the combined verdict and
66
+ * its record metadata. */
67
+ private dispatch;
68
+ /** Invoke one interceptor on its own copy of `ctx` (§7) and pass the
69
+ * result through the §5 gate. Never throws: every failure becomes the
70
+ * §6.3 synthesized deny. */
71
+ private invoke;
72
+ /** §5 gate: normalize a wire verdict or synthesize the §6.3 deny. The
73
+ * non-finite pre-scan runs first — JSON.stringify would silently
74
+ * corrupt NaN/±Infinity to null before the core could reject them. */
75
+ private gate;
76
+ /** `sequential/first_deny` (§7.4): fold-through, first deny
77
+ * short-circuits; a liftable deny consults the seam, then `stop` or
78
+ * `resume` per the knob.
79
+ *
80
+ * `perInterceptor` stays index-aligned with registration order (one
81
+ * entry per invoked interceptor, §10.3 summaries); `pool`
82
+ * additionally holds substituted resolutions for the §7.3 unions. */
83
+ private dispatchFirstDeny;
84
+ /** `sequential/run_all` (§7.4): everything runs, transforms fold
85
+ * through for visibility, severity-max aggregate; the seam is
86
+ * consulted at most once, only when the winner is liftable (which
87
+ * implies every deny in the emission is liftable — a single plain
88
+ * deny already won the severity order). */
89
+ private dispatchRunAll;
90
+ /** Parallel profiles (§7.5): isolated snapshots, no fold; serial
91
+ * dispatch (isolation semantics, not scheduling). */
92
+ private dispatchParallel;
93
+ /** Severity-max aggregation + winner handling, shared by `run_all`
94
+ * and both parallel profiles. Aggregation — including the §7.5
95
+ * transform-conflict and `parallel/unanimous` disagreement synthesis
96
+ * per the declared knobs — delegates to the core's
97
+ * `composeAggregate` so all SDKs agree; the seam consultation and
98
+ * transform application stay here (they call back into user JS). */
99
+ private aggregateAndConsult;
100
+ /** Apply (enforce) or validate (evaluate_only) one transform (§7.4, §8). */
101
+ private foldTransform;
102
+ /** The declared provider's output for `ctx` (§10.1); `null` iff the
103
+ * provider is `null`. Throws {@link AgentHooksCoreError} iff the
104
+ * provider rejected the context (§10.2 fail-closed value domain). */
105
+ private identityOf;
106
+ /** Consult the approval seam for a liftable deny (§9), when the
107
+ * profile conditions allow it: `enforce` mode, not `agent_shutdown`,
108
+ * a resolver registered, and the verdict actually liftable. Enforces
109
+ * the echo rule and the §9 outcome/verdict consistency requirements. */
110
+ private consult;
111
+ }