halo-record 0.1.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/LICENSE +204 -0
- package/README.md +109 -0
- package/dist/anchor.d.ts +22 -0
- package/dist/anchor.js +91 -0
- package/dist/canon.d.ts +8 -0
- package/dist/canon.js +99 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +15 -0
- package/dist/integrations/claudeAgent.d.ts +29 -0
- package/dist/integrations/claudeAgent.js +44 -0
- package/dist/integrations/common.d.ts +53 -0
- package/dist/integrations/common.js +134 -0
- package/dist/integrations/langchain.d.ts +26 -0
- package/dist/integrations/langchain.js +49 -0
- package/dist/integrations/mcp.d.ts +31 -0
- package/dist/integrations/mcp.js +92 -0
- package/dist/integrations/openaiAgents.d.ts +26 -0
- package/dist/integrations/openaiAgents.js +41 -0
- package/dist/integrations/vercel.d.ts +38 -0
- package/dist/integrations/vercel.js +85 -0
- package/dist/record.d.ts +55 -0
- package/dist/record.js +222 -0
- package/dist/redact.d.ts +11 -0
- package/dist/redact.js +75 -0
- package/dist/verify.d.ts +13 -0
- package/dist/verify.js +103 -0
- package/dist/witness.d.ts +8 -0
- package/dist/witness.js +36 -0
- package/halo-record.schema.json +146 -0
- package/package.json +31 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type HaloRecord, type Source } from "../record.ts";
|
|
2
|
+
export type ActionClass = "connector" | "exec" | "data_write" | "data_read" | "network" | "other";
|
|
3
|
+
export declare const ACTION_TYPE_BY_CLASS: Record<ActionClass, string>;
|
|
4
|
+
export declare const CATEGORY_BY_CLASS: Record<ActionClass, string>;
|
|
5
|
+
export declare function classifyTool(toolName: string | null | undefined): ActionClass;
|
|
6
|
+
export declare function deriveScope(cls: ActionClass, toolName: string): string;
|
|
7
|
+
export declare function deriveOutcome(response: unknown, error?: unknown): Record<string, unknown>;
|
|
8
|
+
export interface RecorderLike {
|
|
9
|
+
append(record: HaloRecord): HaloRecord;
|
|
10
|
+
}
|
|
11
|
+
export interface ToolCallOptions {
|
|
12
|
+
response?: unknown;
|
|
13
|
+
error?: unknown;
|
|
14
|
+
agent?: {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
cls?: ActionClass;
|
|
19
|
+
actionType?: string;
|
|
20
|
+
category?: string;
|
|
21
|
+
scope?: string;
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
decision?: string;
|
|
24
|
+
approver?: string;
|
|
25
|
+
subject?: string | {
|
|
26
|
+
id: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
} | null;
|
|
29
|
+
source?: string | Partial<Source> | null;
|
|
30
|
+
summaries?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface ModelCallOptions {
|
|
33
|
+
provider: string;
|
|
34
|
+
model: string;
|
|
35
|
+
zdr?: boolean;
|
|
36
|
+
purpose?: string;
|
|
37
|
+
messages?: number;
|
|
38
|
+
response?: unknown;
|
|
39
|
+
error?: unknown;
|
|
40
|
+
agent?: {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
};
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
subject?: string | {
|
|
46
|
+
id: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
} | null;
|
|
49
|
+
source?: string | Partial<Source> | null;
|
|
50
|
+
summaries?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export declare function recordModelCall(recorder: RecorderLike, opts: ModelCallOptions): HaloRecord;
|
|
53
|
+
export declare function recordToolCall(recorder: RecorderLike, toolName: string, toolInput?: unknown, opts?: ToolCallOptions): HaloRecord;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/* Shared, framework-independent core for the adapters — port of the Python
|
|
2
|
+
integrations/_common.py funnel. Every adapter ultimately does the same
|
|
3
|
+
thing: take a tool name, its input, and an outcome (return value or error),
|
|
4
|
+
and append one Halo Runtime Record. No framework imports here. */
|
|
5
|
+
import { inputHash } from "../canon.js";
|
|
6
|
+
import { redactText } from "../redact.js";
|
|
7
|
+
import { build } from "../record.js";
|
|
8
|
+
export const ACTION_TYPE_BY_CLASS = {
|
|
9
|
+
connector: "tool_call",
|
|
10
|
+
exec: "tool_call",
|
|
11
|
+
data_write: "write",
|
|
12
|
+
data_read: "read",
|
|
13
|
+
network: "network",
|
|
14
|
+
other: "tool_call",
|
|
15
|
+
};
|
|
16
|
+
export const CATEGORY_BY_CLASS = {
|
|
17
|
+
connector: "security",
|
|
18
|
+
exec: "security",
|
|
19
|
+
data_write: "safety",
|
|
20
|
+
data_read: "privacy",
|
|
21
|
+
network: "security",
|
|
22
|
+
other: "security",
|
|
23
|
+
};
|
|
24
|
+
/* Map an arbitrary tool name to a Halo action class. Never returns null
|
|
25
|
+
(adapters decide what to skip) — an unrecognized tool is a generic
|
|
26
|
+
"connector" call, the safe default for a trust-boundary action whose nature
|
|
27
|
+
we can't infer from the name alone. */
|
|
28
|
+
export function classifyTool(toolName) {
|
|
29
|
+
if (!toolName)
|
|
30
|
+
return "connector";
|
|
31
|
+
if (toolName.startsWith("mcp__") || toolName.startsWith("mcp:"))
|
|
32
|
+
return "connector";
|
|
33
|
+
const lowered = toolName.toLowerCase();
|
|
34
|
+
if (["bash", "shell", "exec", "python", "code_interpreter"].includes(lowered))
|
|
35
|
+
return "exec";
|
|
36
|
+
if (["write", "edit", "write_file", "create_file", "put"].includes(lowered))
|
|
37
|
+
return "data_write";
|
|
38
|
+
if (["read", "glob", "grep", "ls", "read_file", "list", "get"].includes(lowered))
|
|
39
|
+
return "data_read";
|
|
40
|
+
if (["webfetch", "websearch", "fetch", "search", "http", "browse"].includes(lowered))
|
|
41
|
+
return "network";
|
|
42
|
+
return "connector";
|
|
43
|
+
}
|
|
44
|
+
export function deriveScope(cls, toolName) {
|
|
45
|
+
if (cls === "connector") {
|
|
46
|
+
const parts = toolName.split("__");
|
|
47
|
+
const server = parts.length > 1 ? parts[1] : "mcp";
|
|
48
|
+
return "mcp:" + server;
|
|
49
|
+
}
|
|
50
|
+
return { data_read: "fs.read", data_write: "fs.write", exec: "exec", network: "network" }[cls] ?? "tool";
|
|
51
|
+
}
|
|
52
|
+
function extractText(obj, depth = 0) {
|
|
53
|
+
if (depth > 6)
|
|
54
|
+
return "";
|
|
55
|
+
if (typeof obj === "string")
|
|
56
|
+
return obj;
|
|
57
|
+
if (Array.isArray(obj))
|
|
58
|
+
return obj.map((i) => extractText(i, depth + 1)).join(" ");
|
|
59
|
+
if (obj !== null && typeof obj === "object") {
|
|
60
|
+
return Object.values(obj).map((v) => extractText(v, depth + 1)).join(" ");
|
|
61
|
+
}
|
|
62
|
+
return String(obj);
|
|
63
|
+
}
|
|
64
|
+
/* Deterministic outcome block: what the call actually did. status is "error"
|
|
65
|
+
only on a thrown error or an explicit error marker in the response — never
|
|
66
|
+
inferred (ledger, not classifier). The full response is hashed into the
|
|
67
|
+
chain; only a redacted summary is stored, never the raw content. */
|
|
68
|
+
export function deriveOutcome(response, error) {
|
|
69
|
+
if (error !== undefined && error !== null) {
|
|
70
|
+
return {
|
|
71
|
+
status: "error",
|
|
72
|
+
summary: redactText(String(error)).slice(0, 200),
|
|
73
|
+
hash: inputHash({ error: String(error) }),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
let status = "ok";
|
|
77
|
+
if (response !== null && typeof response === "object" && !Array.isArray(response)) {
|
|
78
|
+
const r = response;
|
|
79
|
+
if (r["is_error"] || r["error"] || r["status"] === "error")
|
|
80
|
+
status = "error";
|
|
81
|
+
}
|
|
82
|
+
const out = { status, hash: inputHash(response ?? null) };
|
|
83
|
+
const summary = redactText(extractText(response ?? "")).slice(0, 200);
|
|
84
|
+
if (summary)
|
|
85
|
+
out["summary"] = summary;
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/* Record one LLM generation as a first-class action. The buyer's first
|
|
89
|
+
question about a bought agent is "which model saw my data, and was it
|
|
90
|
+
allowed to keep it?" — so model calls get their own loud entry:
|
|
91
|
+
tool=model.generate, scope=model:<provider>, category privacy, with
|
|
92
|
+
provider / model / zero-data-retention / purpose in the (hashed +
|
|
93
|
+
summarized) input. Raw prompts and completions never enter the record. */
|
|
94
|
+
export function recordModelCall(recorder, opts) {
|
|
95
|
+
const toolInput = { provider: opts.provider, model: opts.model };
|
|
96
|
+
if (opts.zdr !== undefined)
|
|
97
|
+
toolInput["zdr"] = Boolean(opts.zdr);
|
|
98
|
+
if (opts.purpose)
|
|
99
|
+
toolInput["purpose"] = opts.purpose;
|
|
100
|
+
if (opts.messages !== undefined)
|
|
101
|
+
toolInput["messages"] = opts.messages;
|
|
102
|
+
return recordToolCall(recorder, "model.generate", toolInput, {
|
|
103
|
+
response: opts.response,
|
|
104
|
+
error: opts.error,
|
|
105
|
+
agent: opts.agent,
|
|
106
|
+
actionType: "tool_call",
|
|
107
|
+
category: "privacy",
|
|
108
|
+
scope: "model:" + opts.provider,
|
|
109
|
+
sessionId: opts.sessionId ?? "local",
|
|
110
|
+
subject: opts.subject,
|
|
111
|
+
source: opts.source,
|
|
112
|
+
summaries: opts.summaries ?? true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/* Build and append one record for a completed tool call — the single funnel
|
|
116
|
+
every adapter goes through, so classification, scope derivation, redaction,
|
|
117
|
+
and hashing behave identically regardless of ecosystem. */
|
|
118
|
+
export function recordToolCall(recorder, toolName, toolInput, opts = {}) {
|
|
119
|
+
const cls = opts.cls ?? classifyTool(toolName);
|
|
120
|
+
const record = build(opts.actionType ?? ACTION_TYPE_BY_CLASS[cls] ?? "tool_call", opts.category ?? CATEGORY_BY_CLASS[cls] ?? "security", {
|
|
121
|
+
tool: toolName,
|
|
122
|
+
toolInput,
|
|
123
|
+
sessionId: opts.sessionId ?? "local",
|
|
124
|
+
agent: opts.agent,
|
|
125
|
+
scope: opts.scope ?? deriveScope(cls, toolName),
|
|
126
|
+
decision: opts.decision ?? "allowed",
|
|
127
|
+
approver: opts.approver,
|
|
128
|
+
outcome: deriveOutcome(opts.response, opts.error),
|
|
129
|
+
subject: opts.subject,
|
|
130
|
+
source: opts.source,
|
|
131
|
+
summaries: opts.summaries ?? true,
|
|
132
|
+
});
|
|
133
|
+
return recorder.append(record);
|
|
134
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type RecorderLike } from "./common.ts";
|
|
2
|
+
import type { Source } from "../record.ts";
|
|
3
|
+
export interface LangChainHandlerOptions {
|
|
4
|
+
category?: string;
|
|
5
|
+
scope?: string;
|
|
6
|
+
sessionId?: string;
|
|
7
|
+
agent?: {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
};
|
|
11
|
+
subject?: string | {
|
|
12
|
+
id: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
} | null;
|
|
15
|
+
source?: string | Partial<Source>;
|
|
16
|
+
summaries?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function createLangChainHandler(recorder: RecorderLike, opts?: LangChainHandlerOptions): {
|
|
19
|
+
name: string;
|
|
20
|
+
handleToolStart(tool: {
|
|
21
|
+
name?: string;
|
|
22
|
+
id?: string[];
|
|
23
|
+
} | undefined, input: string, runId: string): void;
|
|
24
|
+
handleToolEnd(output: unknown, runId: string): void;
|
|
25
|
+
handleToolError(err: unknown, runId: string): void;
|
|
26
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* LangChain.js / LangGraph.js adapter.
|
|
2
|
+
|
|
3
|
+
Returns a plain callback-handler object (LangChain.js accepts handler
|
|
4
|
+
objects in the `callbacks` array — no subclassing, no dependency on the
|
|
5
|
+
langchain package):
|
|
6
|
+
|
|
7
|
+
import { Recorder } from "halo-record";
|
|
8
|
+
import { createLangChainHandler } from "halo-record";
|
|
9
|
+
|
|
10
|
+
const rec = new Recorder("audit.jsonl");
|
|
11
|
+
await agent.invoke(inputs, { callbacks: [createLangChainHandler(rec)] });
|
|
12
|
+
|
|
13
|
+
Mirrors the Python HaloCallbackHandler: tool calls keyed by runId, one
|
|
14
|
+
record emitted on end or error. */
|
|
15
|
+
import { recordToolCall } from "./common.js";
|
|
16
|
+
const AGENT = { id: "langchain", name: "langchain" };
|
|
17
|
+
export function createLangChainHandler(recorder, opts = {}) {
|
|
18
|
+
const pending = new Map();
|
|
19
|
+
const emit = (runId, output, error) => {
|
|
20
|
+
const p = pending.get(runId);
|
|
21
|
+
if (!p)
|
|
22
|
+
return;
|
|
23
|
+
pending.delete(runId);
|
|
24
|
+
recordToolCall(recorder, p.tool, p.input, {
|
|
25
|
+
response: output,
|
|
26
|
+
error,
|
|
27
|
+
agent: opts.agent ?? AGENT,
|
|
28
|
+
category: opts.category,
|
|
29
|
+
scope: opts.scope,
|
|
30
|
+
sessionId: opts.sessionId ?? "local",
|
|
31
|
+
subject: opts.subject,
|
|
32
|
+
source: opts.source ?? "langchain",
|
|
33
|
+
summaries: opts.summaries ?? true,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
name: "halo-record",
|
|
38
|
+
handleToolStart(tool, input, runId) {
|
|
39
|
+
const name = tool?.name ?? (Array.isArray(tool?.id) ? tool.id[tool.id.length - 1] : undefined) ?? "tool";
|
|
40
|
+
pending.set(runId, { tool: name, input });
|
|
41
|
+
},
|
|
42
|
+
handleToolEnd(output, runId) {
|
|
43
|
+
emit(runId, output);
|
|
44
|
+
},
|
|
45
|
+
handleToolError(err, runId) {
|
|
46
|
+
emit(runId, undefined, err);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type RecorderLike } from "./common.ts";
|
|
2
|
+
export interface McpOptions {
|
|
3
|
+
server?: string;
|
|
4
|
+
agent?: {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
};
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
subject?: string | {
|
|
10
|
+
id: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
} | null;
|
|
13
|
+
summaries?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function recordMcpCall(recorder: RecorderLike, name: string, args: unknown, opts?: McpOptions & {
|
|
16
|
+
response?: unknown;
|
|
17
|
+
error?: unknown;
|
|
18
|
+
}): import("../record.ts").HaloRecord;
|
|
19
|
+
interface CallToolParams {
|
|
20
|
+
name: string;
|
|
21
|
+
arguments?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
interface McpClientLike {
|
|
24
|
+
callTool(params: CallToolParams, ...rest: unknown[]): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
export declare function instrumentMcpClient<T extends McpClientLike>(client: T, recorder: RecorderLike, opts?: McpOptions): T;
|
|
27
|
+
type ToolHandler = (request: {
|
|
28
|
+
params: CallToolParams;
|
|
29
|
+
}, ...rest: unknown[]) => Promise<unknown>;
|
|
30
|
+
export declare function wrapMcpToolHandler(handler: ToolHandler, recorder: RecorderLike, opts?: McpOptions): ToolHandler;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/* MCP (Model Context Protocol) adapter — TypeScript SDK.
|
|
2
|
+
|
|
3
|
+
Client side: instrument an MCP client so every callTool is recorded:
|
|
4
|
+
|
|
5
|
+
import { Recorder, instrumentMcpClient } from "halo-record";
|
|
6
|
+
const rec = new Recorder("audit.jsonl");
|
|
7
|
+
instrumentMcpClient(client, rec, { server: "gmail" });
|
|
8
|
+
|
|
9
|
+
Server side: wrap a tool handler so calls are recorded where they execute:
|
|
10
|
+
|
|
11
|
+
server.setRequestHandler(CallToolRequestSchema,
|
|
12
|
+
wrapMcpToolHandler(handler, rec, { server: "gmail" }));
|
|
13
|
+
|
|
14
|
+
Tool names are normalized to mcp__<server>__<name> so they classify as
|
|
15
|
+
connectors with scope mcp:<server> — the same shape the Python adapter and
|
|
16
|
+
the Claude Code hook produce, so reports look identical across on-ramps. */
|
|
17
|
+
import { recordToolCall } from "./common.js";
|
|
18
|
+
const AGENT = { id: "mcp", name: "mcp" };
|
|
19
|
+
/* Flatten an MCP tool result (content blocks + isError) into the payload
|
|
20
|
+
shape deriveOutcome understands — mirrors the Python _result_payload. */
|
|
21
|
+
function resultPayload(response) {
|
|
22
|
+
if (response === null || response === undefined)
|
|
23
|
+
return { is_error: false };
|
|
24
|
+
const r = response;
|
|
25
|
+
const isError = Boolean(r["isError"] ?? r["is_error"]);
|
|
26
|
+
const textParts = [];
|
|
27
|
+
const content = r["content"];
|
|
28
|
+
if (Array.isArray(content)) {
|
|
29
|
+
for (const item of content) {
|
|
30
|
+
const t = item?.["text"];
|
|
31
|
+
if (typeof t === "string")
|
|
32
|
+
textParts.push(t);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const payload = { is_error: isError };
|
|
36
|
+
if (textParts.length)
|
|
37
|
+
payload["summary"] = textParts.join(" ");
|
|
38
|
+
return payload;
|
|
39
|
+
}
|
|
40
|
+
function normalizeName(name, server) {
|
|
41
|
+
return name.startsWith("mcp__") ? name : `mcp__${server}__${name}`;
|
|
42
|
+
}
|
|
43
|
+
/* Record one MCP tool call (use directly when you own the call site). */
|
|
44
|
+
export function recordMcpCall(recorder, name, args, opts = {}) {
|
|
45
|
+
return recordToolCall(recorder, normalizeName(name, opts.server ?? "mcp"), args, {
|
|
46
|
+
response: opts.error === undefined ? resultPayload(opts.response) : undefined,
|
|
47
|
+
error: opts.error,
|
|
48
|
+
agent: opts.agent ?? AGENT,
|
|
49
|
+
cls: "connector",
|
|
50
|
+
sessionId: opts.sessionId ?? "local",
|
|
51
|
+
subject: opts.subject,
|
|
52
|
+
source: "mcp",
|
|
53
|
+
summaries: opts.summaries ?? true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/* Wrap client.callTool so every tool call is recorded. Returns the client
|
|
57
|
+
(now instrumented). Idempotent: a client is only wrapped once. */
|
|
58
|
+
export function instrumentMcpClient(client, recorder, opts = {}) {
|
|
59
|
+
const original = client.callTool?.bind(client);
|
|
60
|
+
if (!original || client.callTool._haloWrapped)
|
|
61
|
+
return client;
|
|
62
|
+
const wrapped = async (params, ...rest) => {
|
|
63
|
+
try {
|
|
64
|
+
const result = await original(params, ...rest);
|
|
65
|
+
recordMcpCall(recorder, params.name, params.arguments, { ...opts, response: result });
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
recordMcpCall(recorder, params.name, params.arguments, { ...opts, error: err });
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
wrapped._haloWrapped = true;
|
|
74
|
+
client.callTool = wrapped;
|
|
75
|
+
return client;
|
|
76
|
+
}
|
|
77
|
+
/* Wrap an MCP server CallToolRequest handler so every executed tool call is
|
|
78
|
+
recorded at the server boundary. */
|
|
79
|
+
export function wrapMcpToolHandler(handler, recorder, opts = {}) {
|
|
80
|
+
return async (request, ...rest) => {
|
|
81
|
+
const { name, arguments: args } = request.params;
|
|
82
|
+
try {
|
|
83
|
+
const result = await handler(request, ...rest);
|
|
84
|
+
recordMcpCall(recorder, name, args, { ...opts, response: result });
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
recordMcpCall(recorder, name, args, { ...opts, error: err });
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type RecorderLike } from "./common.ts";
|
|
2
|
+
import type { Source } from "../record.ts";
|
|
3
|
+
export interface AgentsHooksOptions {
|
|
4
|
+
category?: string;
|
|
5
|
+
scope?: string;
|
|
6
|
+
sessionId?: string;
|
|
7
|
+
agent?: {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
};
|
|
11
|
+
subject?: string | {
|
|
12
|
+
id: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
} | null;
|
|
15
|
+
source?: string | Partial<Source>;
|
|
16
|
+
summaries?: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface ToolLike {
|
|
19
|
+
name?: string;
|
|
20
|
+
toolName?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function createAgentsHooks(recorder: RecorderLike, opts?: AgentsHooksOptions): {
|
|
23
|
+
onToolStart(_context: unknown, _agent: unknown, tool: ToolLike | string): Promise<void>;
|
|
24
|
+
onToolEnd(_context: unknown, _agent: unknown, tool: ToolLike | string, result: unknown): Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* OpenAI Agents SDK (JavaScript) adapter.
|
|
2
|
+
|
|
3
|
+
Returns a hooks object whose onToolStart/onToolEnd methods record each tool
|
|
4
|
+
call — mirrors the Python HaloRunHooks. Pass it wherever the SDK accepts
|
|
5
|
+
run hooks. No dependency on @openai/agents; the shape is structural:
|
|
6
|
+
|
|
7
|
+
import { Recorder, createAgentsHooks } from "halo-record";
|
|
8
|
+
const rec = new Recorder("audit.jsonl");
|
|
9
|
+
const hooks = createAgentsHooks(rec);
|
|
10
|
+
// run(agent, input, { hooks }) — or attach per the SDK version in use
|
|
11
|
+
*/
|
|
12
|
+
import { recordToolCall } from "./common.js";
|
|
13
|
+
const AGENT = { id: "openai_agents", name: "openai_agents" };
|
|
14
|
+
function toolName(tool) {
|
|
15
|
+
if (typeof tool === "string")
|
|
16
|
+
return tool;
|
|
17
|
+
return tool?.name ?? tool?.toolName ?? "tool";
|
|
18
|
+
}
|
|
19
|
+
export function createAgentsHooks(recorder, opts = {}) {
|
|
20
|
+
const pending = new Map();
|
|
21
|
+
return {
|
|
22
|
+
async onToolStart(_context, _agent, tool) {
|
|
23
|
+
pending.set(typeof tool === "object" ? tool : String(tool), toolName(tool));
|
|
24
|
+
},
|
|
25
|
+
async onToolEnd(_context, _agent, tool, result) {
|
|
26
|
+
const key = typeof tool === "object" ? tool : String(tool);
|
|
27
|
+
const name = pending.get(key) ?? toolName(tool);
|
|
28
|
+
pending.delete(key);
|
|
29
|
+
recordToolCall(recorder, name, undefined, {
|
|
30
|
+
response: result,
|
|
31
|
+
agent: opts.agent ?? AGENT,
|
|
32
|
+
category: opts.category,
|
|
33
|
+
scope: opts.scope,
|
|
34
|
+
sessionId: opts.sessionId ?? "local",
|
|
35
|
+
subject: opts.subject,
|
|
36
|
+
source: opts.source ?? "openai_agents",
|
|
37
|
+
summaries: opts.summaries ?? true,
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type RecorderLike } from "./common.ts";
|
|
2
|
+
import type { Source } from "../record.ts";
|
|
3
|
+
export interface VercelOptions {
|
|
4
|
+
category?: string;
|
|
5
|
+
scope?: string;
|
|
6
|
+
sessionId?: string;
|
|
7
|
+
agent?: {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
};
|
|
11
|
+
subject?: string | {
|
|
12
|
+
id: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
} | null;
|
|
15
|
+
source?: string | Partial<Source>;
|
|
16
|
+
summaries?: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface VercelToolLike {
|
|
19
|
+
execute?: (args: unknown, options?: unknown) => Promise<unknown> | unknown;
|
|
20
|
+
[k: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
export declare function wrapVercelTools<T extends Record<string, VercelToolLike>>(tools: T, recorder: RecorderLike, opts?: VercelOptions): T;
|
|
23
|
+
interface StepLike {
|
|
24
|
+
toolCalls?: Array<{
|
|
25
|
+
toolCallId?: string;
|
|
26
|
+
toolName?: string;
|
|
27
|
+
args?: unknown;
|
|
28
|
+
input?: unknown;
|
|
29
|
+
}>;
|
|
30
|
+
toolResults?: Array<{
|
|
31
|
+
toolCallId?: string;
|
|
32
|
+
toolName?: string;
|
|
33
|
+
result?: unknown;
|
|
34
|
+
output?: unknown;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export declare function createStepRecorder(recorder: RecorderLike, opts?: VercelOptions): (step: StepLike) => void;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* Vercel AI SDK adapter.
|
|
2
|
+
|
|
3
|
+
Two on-ramps, both boundary-captured:
|
|
4
|
+
|
|
5
|
+
1. Wrap your tools object — every execute() is recorded, including errors:
|
|
6
|
+
|
|
7
|
+
import { Recorder, wrapVercelTools } from "halo-record";
|
|
8
|
+
const rec = new Recorder("audit.jsonl");
|
|
9
|
+
const result = await generateText({ model, tools: wrapVercelTools(tools, rec), ... });
|
|
10
|
+
|
|
11
|
+
2. Or record from onStepFinish (no tool wrapping; pairs toolCalls with
|
|
12
|
+
toolResults per step):
|
|
13
|
+
|
|
14
|
+
await generateText({ model, tools,
|
|
15
|
+
onStepFinish: createStepRecorder(rec) });
|
|
16
|
+
|
|
17
|
+
No dependency on the `ai` package; shapes are structural. */
|
|
18
|
+
import { recordToolCall } from "./common.js";
|
|
19
|
+
const AGENT = { id: "vercel_ai", name: "vercel_ai" };
|
|
20
|
+
/* Wrap each tool's execute so the call is recorded where it runs. Tools
|
|
21
|
+
without an execute function (client-side tools) pass through untouched. */
|
|
22
|
+
export function wrapVercelTools(tools, recorder, opts = {}) {
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [name, tool] of Object.entries(tools)) {
|
|
25
|
+
if (typeof tool?.execute !== "function") {
|
|
26
|
+
out[name] = tool;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const original = tool.execute.bind(tool);
|
|
30
|
+
out[name] = {
|
|
31
|
+
...tool,
|
|
32
|
+
execute: async (args, options) => {
|
|
33
|
+
try {
|
|
34
|
+
const result = await original(args, options);
|
|
35
|
+
recordToolCall(recorder, name, args, {
|
|
36
|
+
response: result,
|
|
37
|
+
agent: opts.agent ?? AGENT,
|
|
38
|
+
category: opts.category,
|
|
39
|
+
scope: opts.scope,
|
|
40
|
+
sessionId: opts.sessionId ?? "local",
|
|
41
|
+
subject: opts.subject,
|
|
42
|
+
source: opts.source ?? "vercel_ai",
|
|
43
|
+
summaries: opts.summaries ?? true,
|
|
44
|
+
});
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
recordToolCall(recorder, name, args, {
|
|
49
|
+
error: err,
|
|
50
|
+
agent: opts.agent ?? AGENT,
|
|
51
|
+
category: opts.category,
|
|
52
|
+
scope: opts.scope,
|
|
53
|
+
sessionId: opts.sessionId ?? "local",
|
|
54
|
+
subject: opts.subject,
|
|
55
|
+
source: opts.source ?? "vercel_ai",
|
|
56
|
+
summaries: opts.summaries ?? true,
|
|
57
|
+
});
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/* onStepFinish handler: records each toolCall paired with its toolResult.
|
|
66
|
+
Use when you can't (or don't want to) wrap tool execute functions. */
|
|
67
|
+
export function createStepRecorder(recorder, opts = {}) {
|
|
68
|
+
return (step) => {
|
|
69
|
+
const calls = step.toolCalls ?? [];
|
|
70
|
+
const results = new Map((step.toolResults ?? []).map((r) => [r.toolCallId ?? r.toolName, r.result ?? r.output]));
|
|
71
|
+
for (const call of calls) {
|
|
72
|
+
const name = call.toolName ?? "tool";
|
|
73
|
+
recordToolCall(recorder, name, call.args ?? call.input, {
|
|
74
|
+
response: results.get(call.toolCallId ?? name),
|
|
75
|
+
agent: opts.agent ?? AGENT,
|
|
76
|
+
category: opts.category,
|
|
77
|
+
scope: opts.scope,
|
|
78
|
+
sessionId: opts.sessionId ?? "local",
|
|
79
|
+
subject: opts.subject,
|
|
80
|
+
source: opts.source ?? "vercel_ai",
|
|
81
|
+
summaries: opts.summaries ?? true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
package/dist/record.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type Finding } from "./redact.ts";
|
|
2
|
+
export declare const SCHEMA_VERSION = "0.1";
|
|
3
|
+
export declare const ACTION_TYPES: Set<string>;
|
|
4
|
+
export declare const CATEGORIES: Set<string>;
|
|
5
|
+
export interface Source {
|
|
6
|
+
adapter: string;
|
|
7
|
+
via: string;
|
|
8
|
+
capture: "captured" | "ingested";
|
|
9
|
+
}
|
|
10
|
+
export declare const SOURCES: Record<string, Source>;
|
|
11
|
+
export declare function normalizeSource(source: string | Partial<Source> | null | undefined): Source | null;
|
|
12
|
+
export interface BuildOptions {
|
|
13
|
+
tool?: string;
|
|
14
|
+
toolInput?: unknown;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
agent?: {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
};
|
|
20
|
+
scope?: string;
|
|
21
|
+
decision?: string;
|
|
22
|
+
approver?: string;
|
|
23
|
+
findings?: Finding[] | null;
|
|
24
|
+
outcome?: Record<string, unknown> | null;
|
|
25
|
+
ts?: string;
|
|
26
|
+
subject?: string | {
|
|
27
|
+
id: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
} | null;
|
|
30
|
+
source?: string | Partial<Source> | null;
|
|
31
|
+
summaries?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export type HaloRecord = Record<string, unknown>;
|
|
34
|
+
export declare function build(actionType: string, category: string, opts?: BuildOptions): HaloRecord;
|
|
35
|
+
export declare class Recorder {
|
|
36
|
+
#private;
|
|
37
|
+
path: string;
|
|
38
|
+
constructor(path: string);
|
|
39
|
+
lastHash(): string;
|
|
40
|
+
append(record: HaloRecord): HaloRecord;
|
|
41
|
+
record(actionType: string, category: string, opts?: BuildOptions): HaloRecord;
|
|
42
|
+
}
|
|
43
|
+
export declare class TenantRecorder {
|
|
44
|
+
directory: string;
|
|
45
|
+
default: string;
|
|
46
|
+
private recorders;
|
|
47
|
+
constructor(directory: string, opts?: {
|
|
48
|
+
default?: string;
|
|
49
|
+
});
|
|
50
|
+
static safe(name: unknown): string;
|
|
51
|
+
subjectId(record: HaloRecord): string;
|
|
52
|
+
pathFor(subjectId: string): string;
|
|
53
|
+
recorderFor(subjectId: string): Recorder;
|
|
54
|
+
append(record: HaloRecord): HaloRecord;
|
|
55
|
+
}
|