@orangecheck/agent-langgraph 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 +21 -0
- package/README.md +93 -0
- package/dist/index.d.mts +54 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +127 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +119 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +67 -0
- package/src/index.test.ts +63 -0
- package/src/index.ts +295 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OrangeCheck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @orangecheck/agent-langgraph
|
|
2
|
+
|
|
3
|
+
Wrap [LangGraph](https://langchain-ai.github.io/langgraph/) tool-node executions in [OC Agent](https://github.com/orangecheck/oc-agent-protocol) action envelopes. Binds each action to the **graph-state hash** at execution time — so verifiers replaying the audit against a persisted graph snapshot can confirm the action fired against the expected state, not a divergent fork.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
LangGraph composes long-running stateful agents from typed nodes. The compliance question is harder than for stateless adapters: at *what graph state* did this agent take this action? Without a state-bound action receipt, "the model called this tool" loses its temporal grounding the moment the graph forks, retries, or replays.
|
|
8
|
+
|
|
9
|
+
This adapter canonicalizes `(verb, args, callId, graph_state_hash)` — one extra field over the stateless adapters in this family — so the action envelope binds the agent's signature to the exact state snapshot at execution time. Two verifiers re-deriving the action from a persisted graph snapshot get the same envelope id only when the snapshot's state-hash matches.
|
|
10
|
+
|
|
11
|
+
## Status
|
|
12
|
+
|
|
13
|
+
**v0.0.1 · in design.** API shape is stable; the canonicalization layout (`{args, call_id, graph_state_hash, verb}` lexicographic, RFC 8785 canonical JSON, trailing LF) is locked. Production wiring with specific LangGraph release lines is intentionally not baked in — `ocToolNode()` is provider/version-agnostic so you can call it from any release of `@langchain/langgraph` you're already on.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm i @orangecheck/agent-langgraph
|
|
19
|
+
# peer deps:
|
|
20
|
+
npm i @orangecheck/agent-core @orangecheck/agent-signer
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { StateGraph } from '@langchain/langgraph';
|
|
27
|
+
import { ocToolNode } from '@orangecheck/agent-langgraph';
|
|
28
|
+
|
|
29
|
+
const createInvoice = ocToolNode({
|
|
30
|
+
verb: 'invoice.create',
|
|
31
|
+
execute: async (args) => myInvoiceCreateImpl(args),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const graph = new StateGraph(MyState).addNode('createInvoice', async (state) => {
|
|
35
|
+
const args = pickToolArgs(state);
|
|
36
|
+
const callId = state.lastToolCallId;
|
|
37
|
+
|
|
38
|
+
const { result, action } = await createInvoice.execute(args, {
|
|
39
|
+
agent, delegation, callId, graphState: state,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await yourAuditPipeline.append(action);
|
|
43
|
+
return mergeResultIntoState(state, result);
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The pattern: `ocToolNode` defines the OC-Agent-shaped wrapper; your LangGraph node glue passes the live `graphState` so the wrapper hashes it inline.
|
|
48
|
+
|
|
49
|
+
## What gets stamped
|
|
50
|
+
|
|
51
|
+
Stamp's `content.hash` is a SHA-256 of:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"args": <canonical JSON of args>,
|
|
56
|
+
"call_id": "<sdk-assigned tool call id>",
|
|
57
|
+
"graph_state_hash": "sha256:<64-hex>",
|
|
58
|
+
"verb": "<verb>"
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The `graph_state_hash` is itself a `sha256:<64-hex>` over the canonicalized graph state object. Two replays against the same persisted state produce the same hash; a divergent state (extra messages, mutated step counter, anything) produces a different hash and the action envelope will not match.
|
|
63
|
+
|
|
64
|
+
Default `scope_exercised` is `langgraph:tool(verb=<verb>)`. Override via `scopeExercised`.
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
- `graphStateHash(state)` — stable `sha256:<64-hex>` of a LangGraph state object.
|
|
69
|
+
- `canonicalizeToolCall(call)` — RFC 8785 canonical bytes.
|
|
70
|
+
- `toolCallHash(call)` — `sha256:<64-hex>` for `content.hash`.
|
|
71
|
+
- `stampToolCall(input)` — sign without executing.
|
|
72
|
+
- `ocToolNode({verb, execute})` — wrapped tool node with state-aware canonicalization.
|
|
73
|
+
|
|
74
|
+
## Replay verification
|
|
75
|
+
|
|
76
|
+
To verify an action against a persisted graph snapshot:
|
|
77
|
+
|
|
78
|
+
1. Decode the action envelope's content hash.
|
|
79
|
+
2. Recompute `graphStateHash(persisted_state)` and check it equals the `graph_state_hash` declared in the canonicalized content.
|
|
80
|
+
3. Run the standard `verifyAction()` from `@orangecheck/agent-core` against the action + delegation + Bitcoin headers.
|
|
81
|
+
|
|
82
|
+
The replay verifier picks up forks automatically: if the persisted snapshot doesn't match the state at execution time, the `graph_state_hash` differs and the action's content hash won't reproduce.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT.
|
|
87
|
+
|
|
88
|
+
## Related
|
|
89
|
+
|
|
90
|
+
- [oc-agent-protocol](https://github.com/orangecheck/oc-agent-protocol) — normative wire format.
|
|
91
|
+
- [@orangecheck/agent-core](https://www.npmjs.com/package/@orangecheck/agent-core) — verifier library.
|
|
92
|
+
- [@orangecheck/agent-mcp](https://www.npmjs.com/package/@orangecheck/agent-mcp) / [-anthropic](https://www.npmjs.com/package/@orangecheck/agent-anthropic) / [-openai](https://www.npmjs.com/package/@orangecheck/agent-openai) / [-vercel](https://www.npmjs.com/package/@orangecheck/agent-vercel) — sibling adapters, all stateless.
|
|
93
|
+
- [console.ochk.io/integrations/langgraph](https://console.ochk.io/integrations/langgraph) — managed-tier story.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { DelegationEnvelope } from '@orangecheck/agent-core';
|
|
2
|
+
import { SignerRef, ActionEnvelope } from '@orangecheck/agent-signer';
|
|
3
|
+
|
|
4
|
+
declare function graphStateHash(state: Record<string, unknown>): string;
|
|
5
|
+
interface LangGraphToolCall {
|
|
6
|
+
callId: string;
|
|
7
|
+
verb: string;
|
|
8
|
+
args: Record<string, unknown>;
|
|
9
|
+
graphStateHash: string;
|
|
10
|
+
}
|
|
11
|
+
declare function canonicalizeToolCall(c: LangGraphToolCall): Uint8Array;
|
|
12
|
+
declare function toolCallHash(c: LangGraphToolCall): string;
|
|
13
|
+
declare function toolCallActionId(agentAddress: string, signedAt: string, delegationId: string, scopeExercised: string, c: LangGraphToolCall): string;
|
|
14
|
+
interface AgentContext {
|
|
15
|
+
agent: SignerRef;
|
|
16
|
+
delegation: DelegationEnvelope;
|
|
17
|
+
}
|
|
18
|
+
interface StampToolCallInput extends AgentContext {
|
|
19
|
+
call: LangGraphToolCall;
|
|
20
|
+
scopeExercised?: string;
|
|
21
|
+
signedAt?: Date;
|
|
22
|
+
}
|
|
23
|
+
declare function stampToolCall(input: StampToolCallInput): Promise<ActionEnvelope>;
|
|
24
|
+
interface OcToolNodeInput<TArgs extends Record<string, unknown>, TResult> {
|
|
25
|
+
verb: string;
|
|
26
|
+
execute: (args: TArgs) => Promise<TResult>;
|
|
27
|
+
}
|
|
28
|
+
interface OcToolNodeWrapped<TArgs extends Record<string, unknown>, TResult> {
|
|
29
|
+
verb: string;
|
|
30
|
+
execute: (args: TArgs, ctx: AgentContext & {
|
|
31
|
+
callId: string;
|
|
32
|
+
graphState: Record<string, unknown>;
|
|
33
|
+
console?: ConsoleClient;
|
|
34
|
+
}) => Promise<{
|
|
35
|
+
result: TResult;
|
|
36
|
+
action: ActionEnvelope;
|
|
37
|
+
posted: PostActionResult | null;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
declare function ocToolNode<TArgs extends Record<string, unknown>, TResult>(input: OcToolNodeInput<TArgs, TResult>): OcToolNodeWrapped<TArgs, TResult>;
|
|
41
|
+
interface ConsoleClient {
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
apiToken: string;
|
|
44
|
+
projectId: string;
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
}
|
|
47
|
+
interface PostActionResult {
|
|
48
|
+
id: string;
|
|
49
|
+
project_id: string;
|
|
50
|
+
delegation_id: string;
|
|
51
|
+
}
|
|
52
|
+
declare function postActionToConsole(action: ActionEnvelope, client: ConsoleClient): Promise<PostActionResult>;
|
|
53
|
+
|
|
54
|
+
export { type AgentContext, type ConsoleClient, type LangGraphToolCall, type OcToolNodeInput, type OcToolNodeWrapped, type PostActionResult, type StampToolCallInput, canonicalizeToolCall, graphStateHash, ocToolNode, postActionToConsole, stampToolCall, toolCallActionId, toolCallHash };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { DelegationEnvelope } from '@orangecheck/agent-core';
|
|
2
|
+
import { SignerRef, ActionEnvelope } from '@orangecheck/agent-signer';
|
|
3
|
+
|
|
4
|
+
declare function graphStateHash(state: Record<string, unknown>): string;
|
|
5
|
+
interface LangGraphToolCall {
|
|
6
|
+
callId: string;
|
|
7
|
+
verb: string;
|
|
8
|
+
args: Record<string, unknown>;
|
|
9
|
+
graphStateHash: string;
|
|
10
|
+
}
|
|
11
|
+
declare function canonicalizeToolCall(c: LangGraphToolCall): Uint8Array;
|
|
12
|
+
declare function toolCallHash(c: LangGraphToolCall): string;
|
|
13
|
+
declare function toolCallActionId(agentAddress: string, signedAt: string, delegationId: string, scopeExercised: string, c: LangGraphToolCall): string;
|
|
14
|
+
interface AgentContext {
|
|
15
|
+
agent: SignerRef;
|
|
16
|
+
delegation: DelegationEnvelope;
|
|
17
|
+
}
|
|
18
|
+
interface StampToolCallInput extends AgentContext {
|
|
19
|
+
call: LangGraphToolCall;
|
|
20
|
+
scopeExercised?: string;
|
|
21
|
+
signedAt?: Date;
|
|
22
|
+
}
|
|
23
|
+
declare function stampToolCall(input: StampToolCallInput): Promise<ActionEnvelope>;
|
|
24
|
+
interface OcToolNodeInput<TArgs extends Record<string, unknown>, TResult> {
|
|
25
|
+
verb: string;
|
|
26
|
+
execute: (args: TArgs) => Promise<TResult>;
|
|
27
|
+
}
|
|
28
|
+
interface OcToolNodeWrapped<TArgs extends Record<string, unknown>, TResult> {
|
|
29
|
+
verb: string;
|
|
30
|
+
execute: (args: TArgs, ctx: AgentContext & {
|
|
31
|
+
callId: string;
|
|
32
|
+
graphState: Record<string, unknown>;
|
|
33
|
+
console?: ConsoleClient;
|
|
34
|
+
}) => Promise<{
|
|
35
|
+
result: TResult;
|
|
36
|
+
action: ActionEnvelope;
|
|
37
|
+
posted: PostActionResult | null;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
declare function ocToolNode<TArgs extends Record<string, unknown>, TResult>(input: OcToolNodeInput<TArgs, TResult>): OcToolNodeWrapped<TArgs, TResult>;
|
|
41
|
+
interface ConsoleClient {
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
apiToken: string;
|
|
44
|
+
projectId: string;
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
}
|
|
47
|
+
interface PostActionResult {
|
|
48
|
+
id: string;
|
|
49
|
+
project_id: string;
|
|
50
|
+
delegation_id: string;
|
|
51
|
+
}
|
|
52
|
+
declare function postActionToConsole(action: ActionEnvelope, client: ConsoleClient): Promise<PostActionResult>;
|
|
53
|
+
|
|
54
|
+
export { type AgentContext, type ConsoleClient, type LangGraphToolCall, type OcToolNodeInput, type OcToolNodeWrapped, type PostActionResult, type StampToolCallInput, canonicalizeToolCall, graphStateHash, ocToolNode, postActionToConsole, stampToolCall, toolCallActionId, toolCallHash };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var sha256 = require('@noble/hashes/sha256');
|
|
4
|
+
var agentCore = require('@orangecheck/agent-core');
|
|
5
|
+
var agentSigner = require('@orangecheck/agent-signer');
|
|
6
|
+
|
|
7
|
+
// src/index.ts
|
|
8
|
+
function graphStateHash(state) {
|
|
9
|
+
const str = agentCore.canonicalize(state);
|
|
10
|
+
return "sha256:" + agentCore.hexEncode(sha256.sha256(new TextEncoder().encode(str + "\n")));
|
|
11
|
+
}
|
|
12
|
+
function canonicalizeToolCall(c) {
|
|
13
|
+
const canon = {
|
|
14
|
+
args: c.args,
|
|
15
|
+
call_id: c.callId,
|
|
16
|
+
graph_state_hash: c.graphStateHash,
|
|
17
|
+
verb: c.verb
|
|
18
|
+
};
|
|
19
|
+
const str = agentCore.canonicalize(canon);
|
|
20
|
+
return new TextEncoder().encode(str + "\n");
|
|
21
|
+
}
|
|
22
|
+
function toolCallHash(c) {
|
|
23
|
+
return "sha256:" + agentCore.hexEncode(sha256.sha256(canonicalizeToolCall(c)));
|
|
24
|
+
}
|
|
25
|
+
function toolCallActionId(agentAddress, signedAt, delegationId, scopeExercised, c) {
|
|
26
|
+
return agentCore.computeActionId({
|
|
27
|
+
address: agentAddress,
|
|
28
|
+
content_hash: toolCallHash(c),
|
|
29
|
+
content_length: canonicalizeToolCall(c).byteLength,
|
|
30
|
+
content_mime: "application/vnd.oc-agent.langgraph-tool-call+json",
|
|
31
|
+
signed_at: signedAt,
|
|
32
|
+
delegation_id: delegationId,
|
|
33
|
+
scope_exercised: scopeExercised
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function stampToolCall(input) {
|
|
37
|
+
const scopeExercised = input.scopeExercised ?? `langgraph:tool(verb=${input.call.verb})`;
|
|
38
|
+
const granted = (input.delegation.scopes ?? []).map(agentCore.parseScope);
|
|
39
|
+
const exercisedParsed = agentCore.parseScope(scopeExercised);
|
|
40
|
+
if (!granted.some((g) => agentCore.isSubScope(exercisedParsed, g))) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`stampToolCall: scope_exercised (${scopeExercised}) is not a sub-scope of any granted scope`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const bytes = canonicalizeToolCall(input.call);
|
|
46
|
+
return agentSigner.signAsAgent({
|
|
47
|
+
agent: input.agent,
|
|
48
|
+
delegation: input.delegation,
|
|
49
|
+
content: { hash: toolCallHash(input.call), length: bytes.byteLength },
|
|
50
|
+
mime: "application/vnd.oc-agent.langgraph-tool-call+json",
|
|
51
|
+
scopeExercised,
|
|
52
|
+
signedAt: input.signedAt
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function ocToolNode(input) {
|
|
56
|
+
return {
|
|
57
|
+
verb: input.verb,
|
|
58
|
+
execute: async (args, ctx) => {
|
|
59
|
+
const call = {
|
|
60
|
+
callId: ctx.callId,
|
|
61
|
+
verb: input.verb,
|
|
62
|
+
args,
|
|
63
|
+
graphStateHash: graphStateHash(ctx.graphState)
|
|
64
|
+
};
|
|
65
|
+
const action = await stampToolCall({
|
|
66
|
+
agent: ctx.agent,
|
|
67
|
+
delegation: ctx.delegation,
|
|
68
|
+
call
|
|
69
|
+
});
|
|
70
|
+
const result = await input.execute(args);
|
|
71
|
+
let posted = null;
|
|
72
|
+
if (ctx.console) {
|
|
73
|
+
try {
|
|
74
|
+
posted = await postActionToConsole(action, ctx.console);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error("[oc-agent-langgraph] postActionToConsole failed:", err);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { result, action, posted };
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function postActionToConsole(action, client) {
|
|
84
|
+
const baseUrl = client.baseUrl ?? "https://console.ochk.io";
|
|
85
|
+
const f = client.fetch ?? fetch;
|
|
86
|
+
const body = {
|
|
87
|
+
project_id: client.projectId,
|
|
88
|
+
delegation_id: action.delegation_id,
|
|
89
|
+
agent_address: action.signer.address,
|
|
90
|
+
scope_exercised: action.scope_exercised,
|
|
91
|
+
content_hash: action.content.hash,
|
|
92
|
+
content_length: action.content.length,
|
|
93
|
+
content_mime: action.content.mime,
|
|
94
|
+
signed_at: action.signed_at,
|
|
95
|
+
signature: action.sig.value,
|
|
96
|
+
id: action.id
|
|
97
|
+
};
|
|
98
|
+
const r = await f(`${baseUrl}/api/actions`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
Authorization: `Bearer ${client.apiToken}`
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify(body)
|
|
105
|
+
});
|
|
106
|
+
if (!r.ok) {
|
|
107
|
+
let reason = `http_${r.status}`;
|
|
108
|
+
try {
|
|
109
|
+
const j2 = await r.json();
|
|
110
|
+
if (j2.reason) reason = j2.reason;
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`postActionToConsole failed: ${reason}`);
|
|
114
|
+
}
|
|
115
|
+
const j = await r.json();
|
|
116
|
+
return j.action;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
exports.canonicalizeToolCall = canonicalizeToolCall;
|
|
120
|
+
exports.graphStateHash = graphStateHash;
|
|
121
|
+
exports.ocToolNode = ocToolNode;
|
|
122
|
+
exports.postActionToConsole = postActionToConsole;
|
|
123
|
+
exports.stampToolCall = stampToolCall;
|
|
124
|
+
exports.toolCallActionId = toolCallActionId;
|
|
125
|
+
exports.toolCallHash = toolCallHash;
|
|
126
|
+
//# sourceMappingURL=index.js.map
|
|
127
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["canonicalize","hexEncode","sha256","computeActionId","parseScope","isSubScope","signAsAgent","j"],"mappings":"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","file":"index.js","sourcesContent":["// @orangecheck/agent-langgraph — wrap LangGraph tool-node executions in\n// OC Agent action envelopes.\n//\n// LangGraph's distinguishing feature is graph state: stateful agents\n// composed of typed nodes that share a persisted state object. The\n// compliance-relevant question becomes \"at what graph state did this\n// agent take this action?\" — and the answer must be cryptographically\n// bound to the action receipt.\n//\n// This adapter therefore canonicalizes (verb, args, callId, graph_state_hash)\n// — one extra field over the agent-mcp / agent-anthropic / agent-openai /\n// agent-vercel canonicalization. Two verifiers re-deriving the action from\n// a persisted graph snapshot compute the same envelope id only when the\n// snapshot's state-hash matches; tampered or out-of-order replay fails.\n\nimport { sha256 } from '@noble/hashes/sha256';\nimport {\n canonicalize,\n computeActionId,\n hexEncode,\n isSubScope,\n parseScope,\n type DelegationEnvelope,\n} from '@orangecheck/agent-core';\nimport {\n signAsAgent,\n type ActionEnvelope,\n type SignerRef,\n} from '@orangecheck/agent-signer';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Graph state hashing\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Compute a stable hash of a LangGraph state object. Use canonical JSON\n * (RFC 8785) so reordered keys / repeated serializations produce the same\n * hash. Returns `sha256:<64-hex>`.\n *\n * Most LangGraph deployments persist the state object via a checkpointer\n * (in-memory, postgres, redis, etc.). The state-hash here is over the\n * checkpointer's serialized state at the moment the tool node fires.\n */\nexport function graphStateHash(state: Record<string, unknown>): string {\n const str = canonicalize(state as unknown as Parameters<typeof canonicalize>[0]);\n return 'sha256:' + hexEncode(sha256(new TextEncoder().encode(str + '\\n')));\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Tool-call canonicalization\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface LangGraphToolCall {\n /** Stable id assigned by your graph runtime for this tool invocation. */\n callId: string;\n /** Tool verb — typically the node name. */\n verb: string;\n /** JSON-serializable arguments to the tool node. */\n args: Record<string, unknown>;\n /** sha256:<64-hex> of the graph state at the moment of execution. */\n graphStateHash: string;\n}\n\n/**\n * Produce the canonical bytes for a LangGraph tool call.\n *\n * Shape (RFC 8785 canonical JSON, then trailing LF):\n * {\n * \"args\": <canonical JSON of args>,\n * \"call_id\": <callId>,\n * \"graph_state_hash\": <state hash>,\n * \"verb\": <verb>\n * }\n */\nexport function canonicalizeToolCall(c: LangGraphToolCall): Uint8Array {\n const canon = {\n args: c.args,\n call_id: c.callId,\n graph_state_hash: c.graphStateHash,\n verb: c.verb,\n };\n const str = canonicalize(canon as unknown as Parameters<typeof canonicalize>[0]);\n return new TextEncoder().encode(str + '\\n');\n}\n\nexport function toolCallHash(c: LangGraphToolCall): string {\n return 'sha256:' + hexEncode(sha256(canonicalizeToolCall(c)));\n}\n\nexport function toolCallActionId(\n agentAddress: string,\n signedAt: string,\n delegationId: string,\n scopeExercised: string,\n c: LangGraphToolCall\n): string {\n return computeActionId({\n address: agentAddress,\n content_hash: toolCallHash(c),\n content_length: canonicalizeToolCall(c).byteLength,\n content_mime: 'application/vnd.oc-agent.langgraph-tool-call+json',\n signed_at: signedAt,\n delegation_id: delegationId,\n scope_exercised: scopeExercised,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Stamping primitive\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface AgentContext {\n agent: SignerRef;\n delegation: DelegationEnvelope;\n}\n\nexport interface StampToolCallInput extends AgentContext {\n call: LangGraphToolCall;\n /** Defaults to `langgraph:tool(verb=<verb>)` — the tightest admissible sub-scope. */\n scopeExercised?: string;\n signedAt?: Date;\n}\n\nexport async function stampToolCall(input: StampToolCallInput): Promise<ActionEnvelope> {\n const scopeExercised =\n input.scopeExercised ?? `langgraph:tool(verb=${input.call.verb})`;\n\n const granted = (input.delegation.scopes ?? []).map(parseScope);\n const exercisedParsed = parseScope(scopeExercised);\n if (!granted.some((g) => isSubScope(exercisedParsed, g))) {\n throw new Error(\n `stampToolCall: scope_exercised (${scopeExercised}) is not a sub-scope of any granted scope`\n );\n }\n\n const bytes = canonicalizeToolCall(input.call);\n return signAsAgent({\n agent: input.agent,\n delegation: input.delegation as never,\n content: { hash: toolCallHash(input.call), length: bytes.byteLength },\n mime: 'application/vnd.oc-agent.langgraph-tool-call+json',\n scopeExercised,\n signedAt: input.signedAt,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// ocToolNode — convenience wrapper\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface OcToolNodeInput<TArgs extends Record<string, unknown>, TResult> {\n /** OC Agent verb under which this tool is exercised. */\n verb: string;\n /** Your real handler. Receives the same args your LangGraph tool node receives. */\n execute: (args: TArgs) => Promise<TResult>;\n}\n\nexport interface OcToolNodeWrapped<TArgs extends Record<string, unknown>, TResult> {\n verb: string;\n /**\n * The execute fn your LangGraph node calls. Receives args + a context\n * object with the agent ref, delegation, callId, current graph state,\n * and an optional `console` field that triggers fire-and-forget POST\n * to console.ochk.io/api/actions after the underlying execute returns.\n */\n execute: (\n args: TArgs,\n ctx: AgentContext & {\n callId: string;\n graphState: Record<string, unknown>;\n console?: ConsoleClient;\n }\n ) => Promise<{\n result: TResult;\n action: ActionEnvelope;\n posted: PostActionResult | null;\n }>;\n}\n\n/**\n * Wrap a LangGraph tool-node execute path so it produces an OC Agent\n * action envelope per call. The wrapper computes the graph_state_hash\n * inline from the supplied state object, so the verifier replaying the\n * graph against a persisted snapshot gets the same hash deterministically.\n *\n * Typical shape:\n *\n * const createInvoice = ocToolNode({\n * verb: 'invoice.create',\n * execute: async (args) => myInvoiceCreateImpl(args),\n * });\n *\n * const graph = new StateGraph(MyState)\n * .addNode('createInvoice', async (state) => {\n * const args = pickToolArgs(state);\n * const callId = state.lastToolCallId;\n * const { result, action } = await createInvoice.execute(args, {\n * agent, delegation, callId, graphState: state,\n * });\n * await audit.append(action);\n * return mergeResultIntoState(state, result);\n * });\n */\nexport function ocToolNode<TArgs extends Record<string, unknown>, TResult>(\n input: OcToolNodeInput<TArgs, TResult>\n): OcToolNodeWrapped<TArgs, TResult> {\n return {\n verb: input.verb,\n execute: async (args, ctx) => {\n const call: LangGraphToolCall = {\n callId: ctx.callId,\n verb: input.verb,\n args,\n graphStateHash: graphStateHash(ctx.graphState),\n };\n const action = await stampToolCall({\n agent: ctx.agent,\n delegation: ctx.delegation,\n call,\n });\n const result = await input.execute(args);\n let posted: PostActionResult | null = null;\n if (ctx.console) {\n try {\n posted = await postActionToConsole(action, ctx.console);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error('[oc-agent-langgraph] postActionToConsole failed:', err);\n }\n }\n return { result, action, posted };\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Console integration: POST stamped actions to console.ochk.io/api/actions\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface ConsoleClient {\n /** Defaults to https://console.ochk.io. */\n baseUrl?: string;\n /** Bearer token from /settings § 03 (`ock_<hex>`). */\n apiToken: string;\n /** Project the action belongs to (proj_*). */\n projectId: string;\n /** Optional fetch override for runtimes that need it. */\n fetch?: typeof fetch;\n}\n\nexport interface PostActionResult {\n id: string;\n project_id: string;\n delegation_id: string;\n}\n\nexport async function postActionToConsole(\n action: ActionEnvelope,\n client: ConsoleClient\n): Promise<PostActionResult> {\n const baseUrl = client.baseUrl ?? 'https://console.ochk.io';\n const f = client.fetch ?? fetch;\n const body = {\n project_id: client.projectId,\n delegation_id: action.delegation_id,\n agent_address: action.signer.address,\n scope_exercised: action.scope_exercised,\n content_hash: action.content.hash,\n content_length: action.content.length,\n content_mime: action.content.mime,\n signed_at: action.signed_at,\n signature: action.sig.value,\n id: action.id,\n };\n const r = await f(`${baseUrl}/api/actions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${client.apiToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n let reason = `http_${r.status}`;\n try {\n const j = (await r.json()) as { reason?: string };\n if (j.reason) reason = j.reason;\n } catch {\n // body wasn't json\n }\n throw new Error(`postActionToConsole failed: ${reason}`);\n }\n const j = (await r.json()) as { ok: true; action: PostActionResult };\n return j.action;\n}\n"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
2
|
+
import { canonicalize, hexEncode, computeActionId, parseScope, isSubScope } from '@orangecheck/agent-core';
|
|
3
|
+
import { signAsAgent } from '@orangecheck/agent-signer';
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
function graphStateHash(state) {
|
|
7
|
+
const str = canonicalize(state);
|
|
8
|
+
return "sha256:" + hexEncode(sha256(new TextEncoder().encode(str + "\n")));
|
|
9
|
+
}
|
|
10
|
+
function canonicalizeToolCall(c) {
|
|
11
|
+
const canon = {
|
|
12
|
+
args: c.args,
|
|
13
|
+
call_id: c.callId,
|
|
14
|
+
graph_state_hash: c.graphStateHash,
|
|
15
|
+
verb: c.verb
|
|
16
|
+
};
|
|
17
|
+
const str = canonicalize(canon);
|
|
18
|
+
return new TextEncoder().encode(str + "\n");
|
|
19
|
+
}
|
|
20
|
+
function toolCallHash(c) {
|
|
21
|
+
return "sha256:" + hexEncode(sha256(canonicalizeToolCall(c)));
|
|
22
|
+
}
|
|
23
|
+
function toolCallActionId(agentAddress, signedAt, delegationId, scopeExercised, c) {
|
|
24
|
+
return computeActionId({
|
|
25
|
+
address: agentAddress,
|
|
26
|
+
content_hash: toolCallHash(c),
|
|
27
|
+
content_length: canonicalizeToolCall(c).byteLength,
|
|
28
|
+
content_mime: "application/vnd.oc-agent.langgraph-tool-call+json",
|
|
29
|
+
signed_at: signedAt,
|
|
30
|
+
delegation_id: delegationId,
|
|
31
|
+
scope_exercised: scopeExercised
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function stampToolCall(input) {
|
|
35
|
+
const scopeExercised = input.scopeExercised ?? `langgraph:tool(verb=${input.call.verb})`;
|
|
36
|
+
const granted = (input.delegation.scopes ?? []).map(parseScope);
|
|
37
|
+
const exercisedParsed = parseScope(scopeExercised);
|
|
38
|
+
if (!granted.some((g) => isSubScope(exercisedParsed, g))) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`stampToolCall: scope_exercised (${scopeExercised}) is not a sub-scope of any granted scope`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const bytes = canonicalizeToolCall(input.call);
|
|
44
|
+
return signAsAgent({
|
|
45
|
+
agent: input.agent,
|
|
46
|
+
delegation: input.delegation,
|
|
47
|
+
content: { hash: toolCallHash(input.call), length: bytes.byteLength },
|
|
48
|
+
mime: "application/vnd.oc-agent.langgraph-tool-call+json",
|
|
49
|
+
scopeExercised,
|
|
50
|
+
signedAt: input.signedAt
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function ocToolNode(input) {
|
|
54
|
+
return {
|
|
55
|
+
verb: input.verb,
|
|
56
|
+
execute: async (args, ctx) => {
|
|
57
|
+
const call = {
|
|
58
|
+
callId: ctx.callId,
|
|
59
|
+
verb: input.verb,
|
|
60
|
+
args,
|
|
61
|
+
graphStateHash: graphStateHash(ctx.graphState)
|
|
62
|
+
};
|
|
63
|
+
const action = await stampToolCall({
|
|
64
|
+
agent: ctx.agent,
|
|
65
|
+
delegation: ctx.delegation,
|
|
66
|
+
call
|
|
67
|
+
});
|
|
68
|
+
const result = await input.execute(args);
|
|
69
|
+
let posted = null;
|
|
70
|
+
if (ctx.console) {
|
|
71
|
+
try {
|
|
72
|
+
posted = await postActionToConsole(action, ctx.console);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error("[oc-agent-langgraph] postActionToConsole failed:", err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { result, action, posted };
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function postActionToConsole(action, client) {
|
|
82
|
+
const baseUrl = client.baseUrl ?? "https://console.ochk.io";
|
|
83
|
+
const f = client.fetch ?? fetch;
|
|
84
|
+
const body = {
|
|
85
|
+
project_id: client.projectId,
|
|
86
|
+
delegation_id: action.delegation_id,
|
|
87
|
+
agent_address: action.signer.address,
|
|
88
|
+
scope_exercised: action.scope_exercised,
|
|
89
|
+
content_hash: action.content.hash,
|
|
90
|
+
content_length: action.content.length,
|
|
91
|
+
content_mime: action.content.mime,
|
|
92
|
+
signed_at: action.signed_at,
|
|
93
|
+
signature: action.sig.value,
|
|
94
|
+
id: action.id
|
|
95
|
+
};
|
|
96
|
+
const r = await f(`${baseUrl}/api/actions`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
Authorization: `Bearer ${client.apiToken}`
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify(body)
|
|
103
|
+
});
|
|
104
|
+
if (!r.ok) {
|
|
105
|
+
let reason = `http_${r.status}`;
|
|
106
|
+
try {
|
|
107
|
+
const j2 = await r.json();
|
|
108
|
+
if (j2.reason) reason = j2.reason;
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`postActionToConsole failed: ${reason}`);
|
|
112
|
+
}
|
|
113
|
+
const j = await r.json();
|
|
114
|
+
return j.action;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { canonicalizeToolCall, graphStateHash, ocToolNode, postActionToConsole, stampToolCall, toolCallActionId, toolCallHash };
|
|
118
|
+
//# sourceMappingURL=index.mjs.map
|
|
119
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["j"],"mappings":"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","file":"index.mjs","sourcesContent":["// @orangecheck/agent-langgraph — wrap LangGraph tool-node executions in\n// OC Agent action envelopes.\n//\n// LangGraph's distinguishing feature is graph state: stateful agents\n// composed of typed nodes that share a persisted state object. The\n// compliance-relevant question becomes \"at what graph state did this\n// agent take this action?\" — and the answer must be cryptographically\n// bound to the action receipt.\n//\n// This adapter therefore canonicalizes (verb, args, callId, graph_state_hash)\n// — one extra field over the agent-mcp / agent-anthropic / agent-openai /\n// agent-vercel canonicalization. Two verifiers re-deriving the action from\n// a persisted graph snapshot compute the same envelope id only when the\n// snapshot's state-hash matches; tampered or out-of-order replay fails.\n\nimport { sha256 } from '@noble/hashes/sha256';\nimport {\n canonicalize,\n computeActionId,\n hexEncode,\n isSubScope,\n parseScope,\n type DelegationEnvelope,\n} from '@orangecheck/agent-core';\nimport {\n signAsAgent,\n type ActionEnvelope,\n type SignerRef,\n} from '@orangecheck/agent-signer';\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Graph state hashing\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Compute a stable hash of a LangGraph state object. Use canonical JSON\n * (RFC 8785) so reordered keys / repeated serializations produce the same\n * hash. Returns `sha256:<64-hex>`.\n *\n * Most LangGraph deployments persist the state object via a checkpointer\n * (in-memory, postgres, redis, etc.). The state-hash here is over the\n * checkpointer's serialized state at the moment the tool node fires.\n */\nexport function graphStateHash(state: Record<string, unknown>): string {\n const str = canonicalize(state as unknown as Parameters<typeof canonicalize>[0]);\n return 'sha256:' + hexEncode(sha256(new TextEncoder().encode(str + '\\n')));\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Tool-call canonicalization\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface LangGraphToolCall {\n /** Stable id assigned by your graph runtime for this tool invocation. */\n callId: string;\n /** Tool verb — typically the node name. */\n verb: string;\n /** JSON-serializable arguments to the tool node. */\n args: Record<string, unknown>;\n /** sha256:<64-hex> of the graph state at the moment of execution. */\n graphStateHash: string;\n}\n\n/**\n * Produce the canonical bytes for a LangGraph tool call.\n *\n * Shape (RFC 8785 canonical JSON, then trailing LF):\n * {\n * \"args\": <canonical JSON of args>,\n * \"call_id\": <callId>,\n * \"graph_state_hash\": <state hash>,\n * \"verb\": <verb>\n * }\n */\nexport function canonicalizeToolCall(c: LangGraphToolCall): Uint8Array {\n const canon = {\n args: c.args,\n call_id: c.callId,\n graph_state_hash: c.graphStateHash,\n verb: c.verb,\n };\n const str = canonicalize(canon as unknown as Parameters<typeof canonicalize>[0]);\n return new TextEncoder().encode(str + '\\n');\n}\n\nexport function toolCallHash(c: LangGraphToolCall): string {\n return 'sha256:' + hexEncode(sha256(canonicalizeToolCall(c)));\n}\n\nexport function toolCallActionId(\n agentAddress: string,\n signedAt: string,\n delegationId: string,\n scopeExercised: string,\n c: LangGraphToolCall\n): string {\n return computeActionId({\n address: agentAddress,\n content_hash: toolCallHash(c),\n content_length: canonicalizeToolCall(c).byteLength,\n content_mime: 'application/vnd.oc-agent.langgraph-tool-call+json',\n signed_at: signedAt,\n delegation_id: delegationId,\n scope_exercised: scopeExercised,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Stamping primitive\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface AgentContext {\n agent: SignerRef;\n delegation: DelegationEnvelope;\n}\n\nexport interface StampToolCallInput extends AgentContext {\n call: LangGraphToolCall;\n /** Defaults to `langgraph:tool(verb=<verb>)` — the tightest admissible sub-scope. */\n scopeExercised?: string;\n signedAt?: Date;\n}\n\nexport async function stampToolCall(input: StampToolCallInput): Promise<ActionEnvelope> {\n const scopeExercised =\n input.scopeExercised ?? `langgraph:tool(verb=${input.call.verb})`;\n\n const granted = (input.delegation.scopes ?? []).map(parseScope);\n const exercisedParsed = parseScope(scopeExercised);\n if (!granted.some((g) => isSubScope(exercisedParsed, g))) {\n throw new Error(\n `stampToolCall: scope_exercised (${scopeExercised}) is not a sub-scope of any granted scope`\n );\n }\n\n const bytes = canonicalizeToolCall(input.call);\n return signAsAgent({\n agent: input.agent,\n delegation: input.delegation as never,\n content: { hash: toolCallHash(input.call), length: bytes.byteLength },\n mime: 'application/vnd.oc-agent.langgraph-tool-call+json',\n scopeExercised,\n signedAt: input.signedAt,\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// ocToolNode — convenience wrapper\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface OcToolNodeInput<TArgs extends Record<string, unknown>, TResult> {\n /** OC Agent verb under which this tool is exercised. */\n verb: string;\n /** Your real handler. Receives the same args your LangGraph tool node receives. */\n execute: (args: TArgs) => Promise<TResult>;\n}\n\nexport interface OcToolNodeWrapped<TArgs extends Record<string, unknown>, TResult> {\n verb: string;\n /**\n * The execute fn your LangGraph node calls. Receives args + a context\n * object with the agent ref, delegation, callId, current graph state,\n * and an optional `console` field that triggers fire-and-forget POST\n * to console.ochk.io/api/actions after the underlying execute returns.\n */\n execute: (\n args: TArgs,\n ctx: AgentContext & {\n callId: string;\n graphState: Record<string, unknown>;\n console?: ConsoleClient;\n }\n ) => Promise<{\n result: TResult;\n action: ActionEnvelope;\n posted: PostActionResult | null;\n }>;\n}\n\n/**\n * Wrap a LangGraph tool-node execute path so it produces an OC Agent\n * action envelope per call. The wrapper computes the graph_state_hash\n * inline from the supplied state object, so the verifier replaying the\n * graph against a persisted snapshot gets the same hash deterministically.\n *\n * Typical shape:\n *\n * const createInvoice = ocToolNode({\n * verb: 'invoice.create',\n * execute: async (args) => myInvoiceCreateImpl(args),\n * });\n *\n * const graph = new StateGraph(MyState)\n * .addNode('createInvoice', async (state) => {\n * const args = pickToolArgs(state);\n * const callId = state.lastToolCallId;\n * const { result, action } = await createInvoice.execute(args, {\n * agent, delegation, callId, graphState: state,\n * });\n * await audit.append(action);\n * return mergeResultIntoState(state, result);\n * });\n */\nexport function ocToolNode<TArgs extends Record<string, unknown>, TResult>(\n input: OcToolNodeInput<TArgs, TResult>\n): OcToolNodeWrapped<TArgs, TResult> {\n return {\n verb: input.verb,\n execute: async (args, ctx) => {\n const call: LangGraphToolCall = {\n callId: ctx.callId,\n verb: input.verb,\n args,\n graphStateHash: graphStateHash(ctx.graphState),\n };\n const action = await stampToolCall({\n agent: ctx.agent,\n delegation: ctx.delegation,\n call,\n });\n const result = await input.execute(args);\n let posted: PostActionResult | null = null;\n if (ctx.console) {\n try {\n posted = await postActionToConsole(action, ctx.console);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error('[oc-agent-langgraph] postActionToConsole failed:', err);\n }\n }\n return { result, action, posted };\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Console integration: POST stamped actions to console.ochk.io/api/actions\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface ConsoleClient {\n /** Defaults to https://console.ochk.io. */\n baseUrl?: string;\n /** Bearer token from /settings § 03 (`ock_<hex>`). */\n apiToken: string;\n /** Project the action belongs to (proj_*). */\n projectId: string;\n /** Optional fetch override for runtimes that need it. */\n fetch?: typeof fetch;\n}\n\nexport interface PostActionResult {\n id: string;\n project_id: string;\n delegation_id: string;\n}\n\nexport async function postActionToConsole(\n action: ActionEnvelope,\n client: ConsoleClient\n): Promise<PostActionResult> {\n const baseUrl = client.baseUrl ?? 'https://console.ochk.io';\n const f = client.fetch ?? fetch;\n const body = {\n project_id: client.projectId,\n delegation_id: action.delegation_id,\n agent_address: action.signer.address,\n scope_exercised: action.scope_exercised,\n content_hash: action.content.hash,\n content_length: action.content.length,\n content_mime: action.content.mime,\n signed_at: action.signed_at,\n signature: action.sig.value,\n id: action.id,\n };\n const r = await f(`${baseUrl}/api/actions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${client.apiToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n let reason = `http_${r.status}`;\n try {\n const j = (await r.json()) as { reason?: string };\n if (j.reason) reason = j.reason;\n } catch {\n // body wasn't json\n }\n throw new Error(`postActionToConsole failed: ${reason}`);\n }\n const j = (await r.json()) as { ok: true; action: PostActionResult };\n return j.action;\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orangecheck/agent-langgraph",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Wrap LangGraph tool nodes with OC Agent scope enforcement and emit a signed agent-action envelope per execution. Binds the action to the graph-state hash so verifiers replay the audit against the persisted graph snapshot.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bitcoin",
|
|
7
|
+
"agent",
|
|
8
|
+
"authority",
|
|
9
|
+
"langgraph",
|
|
10
|
+
"langchain",
|
|
11
|
+
"graph",
|
|
12
|
+
"tool-calls",
|
|
13
|
+
"bip322",
|
|
14
|
+
"oc-agent",
|
|
15
|
+
"orangecheck"
|
|
16
|
+
],
|
|
17
|
+
"author": "OrangeCheck",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/orangecheck/oc-packages.git",
|
|
22
|
+
"directory": "agent-langgraph"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/orangecheck/oc-agent-protocol",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/orangecheck/oc-agent-protocol/issues"
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"module": "./dist/index.mjs",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.mjs",
|
|
35
|
+
"require": "./dist/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup",
|
|
46
|
+
"dev": "tsup --watch",
|
|
47
|
+
"type-check": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"clean": "rm -rf dist",
|
|
51
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@noble/hashes": "^1.5.0",
|
|
55
|
+
"@orangecheck/agent-core": "^0.3.0",
|
|
56
|
+
"@orangecheck/agent-signer": "^0.1.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^22.10.2",
|
|
60
|
+
"tsup": "^8.3.5",
|
|
61
|
+
"typescript": "^5.7.2",
|
|
62
|
+
"vitest": "^3.2.4"
|
|
63
|
+
},
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
canonicalizeToolCall,
|
|
5
|
+
graphStateHash,
|
|
6
|
+
toolCallHash,
|
|
7
|
+
} from './index.js';
|
|
8
|
+
|
|
9
|
+
describe('graphStateHash', () => {
|
|
10
|
+
it('produces the same hash for key-reordered state', () => {
|
|
11
|
+
const a = graphStateHash({ messages: [], step: 1, user: 'alice' });
|
|
12
|
+
const b = graphStateHash({ user: 'alice', messages: [], step: 1 });
|
|
13
|
+
expect(a).toEqual(b);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('changes when state changes', () => {
|
|
17
|
+
const a = graphStateHash({ step: 1 });
|
|
18
|
+
const b = graphStateHash({ step: 2 });
|
|
19
|
+
expect(a).not.toBe(b);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('canonicalizeToolCall', () => {
|
|
24
|
+
it('produces byte-identical output for identical inputs (key reorder)', () => {
|
|
25
|
+
const stateHash = graphStateHash({ step: 1 });
|
|
26
|
+
const a = canonicalizeToolCall({
|
|
27
|
+
callId: 'tc_1',
|
|
28
|
+
verb: 'invoice.create',
|
|
29
|
+
args: { customer: 'acme', amount: 14.2 },
|
|
30
|
+
graphStateHash: stateHash,
|
|
31
|
+
});
|
|
32
|
+
const b = canonicalizeToolCall({
|
|
33
|
+
graphStateHash: stateHash,
|
|
34
|
+
verb: 'invoice.create',
|
|
35
|
+
callId: 'tc_1',
|
|
36
|
+
args: { amount: 14.2, customer: 'acme' },
|
|
37
|
+
});
|
|
38
|
+
expect(a).toEqual(b);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('hash changes when graph state changes', () => {
|
|
42
|
+
const a = toolCallHash({
|
|
43
|
+
callId: 't',
|
|
44
|
+
verb: 'x',
|
|
45
|
+
args: {},
|
|
46
|
+
graphStateHash: graphStateHash({ step: 1 }),
|
|
47
|
+
});
|
|
48
|
+
const b = toolCallHash({
|
|
49
|
+
callId: 't',
|
|
50
|
+
verb: 'x',
|
|
51
|
+
args: {},
|
|
52
|
+
graphStateHash: graphStateHash({ step: 2 }),
|
|
53
|
+
});
|
|
54
|
+
expect(a).not.toBe(b);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('hash changes when args change', () => {
|
|
58
|
+
const stateHash = graphStateHash({ step: 1 });
|
|
59
|
+
const a = toolCallHash({ callId: 't', verb: 'x', args: { a: 1 }, graphStateHash: stateHash });
|
|
60
|
+
const b = toolCallHash({ callId: 't', verb: 'x', args: { a: 2 }, graphStateHash: stateHash });
|
|
61
|
+
expect(a).not.toBe(b);
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// @orangecheck/agent-langgraph — wrap LangGraph tool-node executions in
|
|
2
|
+
// OC Agent action envelopes.
|
|
3
|
+
//
|
|
4
|
+
// LangGraph's distinguishing feature is graph state: stateful agents
|
|
5
|
+
// composed of typed nodes that share a persisted state object. The
|
|
6
|
+
// compliance-relevant question becomes "at what graph state did this
|
|
7
|
+
// agent take this action?" — and the answer must be cryptographically
|
|
8
|
+
// bound to the action receipt.
|
|
9
|
+
//
|
|
10
|
+
// This adapter therefore canonicalizes (verb, args, callId, graph_state_hash)
|
|
11
|
+
// — one extra field over the agent-mcp / agent-anthropic / agent-openai /
|
|
12
|
+
// agent-vercel canonicalization. Two verifiers re-deriving the action from
|
|
13
|
+
// a persisted graph snapshot compute the same envelope id only when the
|
|
14
|
+
// snapshot's state-hash matches; tampered or out-of-order replay fails.
|
|
15
|
+
|
|
16
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
17
|
+
import {
|
|
18
|
+
canonicalize,
|
|
19
|
+
computeActionId,
|
|
20
|
+
hexEncode,
|
|
21
|
+
isSubScope,
|
|
22
|
+
parseScope,
|
|
23
|
+
type DelegationEnvelope,
|
|
24
|
+
} from '@orangecheck/agent-core';
|
|
25
|
+
import {
|
|
26
|
+
signAsAgent,
|
|
27
|
+
type ActionEnvelope,
|
|
28
|
+
type SignerRef,
|
|
29
|
+
} from '@orangecheck/agent-signer';
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Graph state hashing
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute a stable hash of a LangGraph state object. Use canonical JSON
|
|
37
|
+
* (RFC 8785) so reordered keys / repeated serializations produce the same
|
|
38
|
+
* hash. Returns `sha256:<64-hex>`.
|
|
39
|
+
*
|
|
40
|
+
* Most LangGraph deployments persist the state object via a checkpointer
|
|
41
|
+
* (in-memory, postgres, redis, etc.). The state-hash here is over the
|
|
42
|
+
* checkpointer's serialized state at the moment the tool node fires.
|
|
43
|
+
*/
|
|
44
|
+
export function graphStateHash(state: Record<string, unknown>): string {
|
|
45
|
+
const str = canonicalize(state as unknown as Parameters<typeof canonicalize>[0]);
|
|
46
|
+
return 'sha256:' + hexEncode(sha256(new TextEncoder().encode(str + '\n')));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
// Tool-call canonicalization
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface LangGraphToolCall {
|
|
54
|
+
/** Stable id assigned by your graph runtime for this tool invocation. */
|
|
55
|
+
callId: string;
|
|
56
|
+
/** Tool verb — typically the node name. */
|
|
57
|
+
verb: string;
|
|
58
|
+
/** JSON-serializable arguments to the tool node. */
|
|
59
|
+
args: Record<string, unknown>;
|
|
60
|
+
/** sha256:<64-hex> of the graph state at the moment of execution. */
|
|
61
|
+
graphStateHash: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Produce the canonical bytes for a LangGraph tool call.
|
|
66
|
+
*
|
|
67
|
+
* Shape (RFC 8785 canonical JSON, then trailing LF):
|
|
68
|
+
* {
|
|
69
|
+
* "args": <canonical JSON of args>,
|
|
70
|
+
* "call_id": <callId>,
|
|
71
|
+
* "graph_state_hash": <state hash>,
|
|
72
|
+
* "verb": <verb>
|
|
73
|
+
* }
|
|
74
|
+
*/
|
|
75
|
+
export function canonicalizeToolCall(c: LangGraphToolCall): Uint8Array {
|
|
76
|
+
const canon = {
|
|
77
|
+
args: c.args,
|
|
78
|
+
call_id: c.callId,
|
|
79
|
+
graph_state_hash: c.graphStateHash,
|
|
80
|
+
verb: c.verb,
|
|
81
|
+
};
|
|
82
|
+
const str = canonicalize(canon as unknown as Parameters<typeof canonicalize>[0]);
|
|
83
|
+
return new TextEncoder().encode(str + '\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function toolCallHash(c: LangGraphToolCall): string {
|
|
87
|
+
return 'sha256:' + hexEncode(sha256(canonicalizeToolCall(c)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function toolCallActionId(
|
|
91
|
+
agentAddress: string,
|
|
92
|
+
signedAt: string,
|
|
93
|
+
delegationId: string,
|
|
94
|
+
scopeExercised: string,
|
|
95
|
+
c: LangGraphToolCall
|
|
96
|
+
): string {
|
|
97
|
+
return computeActionId({
|
|
98
|
+
address: agentAddress,
|
|
99
|
+
content_hash: toolCallHash(c),
|
|
100
|
+
content_length: canonicalizeToolCall(c).byteLength,
|
|
101
|
+
content_mime: 'application/vnd.oc-agent.langgraph-tool-call+json',
|
|
102
|
+
signed_at: signedAt,
|
|
103
|
+
delegation_id: delegationId,
|
|
104
|
+
scope_exercised: scopeExercised,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
// Stamping primitive
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export interface AgentContext {
|
|
113
|
+
agent: SignerRef;
|
|
114
|
+
delegation: DelegationEnvelope;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface StampToolCallInput extends AgentContext {
|
|
118
|
+
call: LangGraphToolCall;
|
|
119
|
+
/** Defaults to `langgraph:tool(verb=<verb>)` — the tightest admissible sub-scope. */
|
|
120
|
+
scopeExercised?: string;
|
|
121
|
+
signedAt?: Date;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function stampToolCall(input: StampToolCallInput): Promise<ActionEnvelope> {
|
|
125
|
+
const scopeExercised =
|
|
126
|
+
input.scopeExercised ?? `langgraph:tool(verb=${input.call.verb})`;
|
|
127
|
+
|
|
128
|
+
const granted = (input.delegation.scopes ?? []).map(parseScope);
|
|
129
|
+
const exercisedParsed = parseScope(scopeExercised);
|
|
130
|
+
if (!granted.some((g) => isSubScope(exercisedParsed, g))) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`stampToolCall: scope_exercised (${scopeExercised}) is not a sub-scope of any granted scope`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const bytes = canonicalizeToolCall(input.call);
|
|
137
|
+
return signAsAgent({
|
|
138
|
+
agent: input.agent,
|
|
139
|
+
delegation: input.delegation as never,
|
|
140
|
+
content: { hash: toolCallHash(input.call), length: bytes.byteLength },
|
|
141
|
+
mime: 'application/vnd.oc-agent.langgraph-tool-call+json',
|
|
142
|
+
scopeExercised,
|
|
143
|
+
signedAt: input.signedAt,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// ocToolNode — convenience wrapper
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export interface OcToolNodeInput<TArgs extends Record<string, unknown>, TResult> {
|
|
152
|
+
/** OC Agent verb under which this tool is exercised. */
|
|
153
|
+
verb: string;
|
|
154
|
+
/** Your real handler. Receives the same args your LangGraph tool node receives. */
|
|
155
|
+
execute: (args: TArgs) => Promise<TResult>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface OcToolNodeWrapped<TArgs extends Record<string, unknown>, TResult> {
|
|
159
|
+
verb: string;
|
|
160
|
+
/**
|
|
161
|
+
* The execute fn your LangGraph node calls. Receives args + a context
|
|
162
|
+
* object with the agent ref, delegation, callId, current graph state,
|
|
163
|
+
* and an optional `console` field that triggers fire-and-forget POST
|
|
164
|
+
* to console.ochk.io/api/actions after the underlying execute returns.
|
|
165
|
+
*/
|
|
166
|
+
execute: (
|
|
167
|
+
args: TArgs,
|
|
168
|
+
ctx: AgentContext & {
|
|
169
|
+
callId: string;
|
|
170
|
+
graphState: Record<string, unknown>;
|
|
171
|
+
console?: ConsoleClient;
|
|
172
|
+
}
|
|
173
|
+
) => Promise<{
|
|
174
|
+
result: TResult;
|
|
175
|
+
action: ActionEnvelope;
|
|
176
|
+
posted: PostActionResult | null;
|
|
177
|
+
}>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Wrap a LangGraph tool-node execute path so it produces an OC Agent
|
|
182
|
+
* action envelope per call. The wrapper computes the graph_state_hash
|
|
183
|
+
* inline from the supplied state object, so the verifier replaying the
|
|
184
|
+
* graph against a persisted snapshot gets the same hash deterministically.
|
|
185
|
+
*
|
|
186
|
+
* Typical shape:
|
|
187
|
+
*
|
|
188
|
+
* const createInvoice = ocToolNode({
|
|
189
|
+
* verb: 'invoice.create',
|
|
190
|
+
* execute: async (args) => myInvoiceCreateImpl(args),
|
|
191
|
+
* });
|
|
192
|
+
*
|
|
193
|
+
* const graph = new StateGraph(MyState)
|
|
194
|
+
* .addNode('createInvoice', async (state) => {
|
|
195
|
+
* const args = pickToolArgs(state);
|
|
196
|
+
* const callId = state.lastToolCallId;
|
|
197
|
+
* const { result, action } = await createInvoice.execute(args, {
|
|
198
|
+
* agent, delegation, callId, graphState: state,
|
|
199
|
+
* });
|
|
200
|
+
* await audit.append(action);
|
|
201
|
+
* return mergeResultIntoState(state, result);
|
|
202
|
+
* });
|
|
203
|
+
*/
|
|
204
|
+
export function ocToolNode<TArgs extends Record<string, unknown>, TResult>(
|
|
205
|
+
input: OcToolNodeInput<TArgs, TResult>
|
|
206
|
+
): OcToolNodeWrapped<TArgs, TResult> {
|
|
207
|
+
return {
|
|
208
|
+
verb: input.verb,
|
|
209
|
+
execute: async (args, ctx) => {
|
|
210
|
+
const call: LangGraphToolCall = {
|
|
211
|
+
callId: ctx.callId,
|
|
212
|
+
verb: input.verb,
|
|
213
|
+
args,
|
|
214
|
+
graphStateHash: graphStateHash(ctx.graphState),
|
|
215
|
+
};
|
|
216
|
+
const action = await stampToolCall({
|
|
217
|
+
agent: ctx.agent,
|
|
218
|
+
delegation: ctx.delegation,
|
|
219
|
+
call,
|
|
220
|
+
});
|
|
221
|
+
const result = await input.execute(args);
|
|
222
|
+
let posted: PostActionResult | null = null;
|
|
223
|
+
if (ctx.console) {
|
|
224
|
+
try {
|
|
225
|
+
posted = await postActionToConsole(action, ctx.console);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// eslint-disable-next-line no-console
|
|
228
|
+
console.error('[oc-agent-langgraph] postActionToConsole failed:', err);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { result, action, posted };
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
237
|
+
// Console integration: POST stamped actions to console.ochk.io/api/actions
|
|
238
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
export interface ConsoleClient {
|
|
241
|
+
/** Defaults to https://console.ochk.io. */
|
|
242
|
+
baseUrl?: string;
|
|
243
|
+
/** Bearer token from /settings § 03 (`ock_<hex>`). */
|
|
244
|
+
apiToken: string;
|
|
245
|
+
/** Project the action belongs to (proj_*). */
|
|
246
|
+
projectId: string;
|
|
247
|
+
/** Optional fetch override for runtimes that need it. */
|
|
248
|
+
fetch?: typeof fetch;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface PostActionResult {
|
|
252
|
+
id: string;
|
|
253
|
+
project_id: string;
|
|
254
|
+
delegation_id: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function postActionToConsole(
|
|
258
|
+
action: ActionEnvelope,
|
|
259
|
+
client: ConsoleClient
|
|
260
|
+
): Promise<PostActionResult> {
|
|
261
|
+
const baseUrl = client.baseUrl ?? 'https://console.ochk.io';
|
|
262
|
+
const f = client.fetch ?? fetch;
|
|
263
|
+
const body = {
|
|
264
|
+
project_id: client.projectId,
|
|
265
|
+
delegation_id: action.delegation_id,
|
|
266
|
+
agent_address: action.signer.address,
|
|
267
|
+
scope_exercised: action.scope_exercised,
|
|
268
|
+
content_hash: action.content.hash,
|
|
269
|
+
content_length: action.content.length,
|
|
270
|
+
content_mime: action.content.mime,
|
|
271
|
+
signed_at: action.signed_at,
|
|
272
|
+
signature: action.sig.value,
|
|
273
|
+
id: action.id,
|
|
274
|
+
};
|
|
275
|
+
const r = await f(`${baseUrl}/api/actions`, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: {
|
|
278
|
+
'Content-Type': 'application/json',
|
|
279
|
+
Authorization: `Bearer ${client.apiToken}`,
|
|
280
|
+
},
|
|
281
|
+
body: JSON.stringify(body),
|
|
282
|
+
});
|
|
283
|
+
if (!r.ok) {
|
|
284
|
+
let reason = `http_${r.status}`;
|
|
285
|
+
try {
|
|
286
|
+
const j = (await r.json()) as { reason?: string };
|
|
287
|
+
if (j.reason) reason = j.reason;
|
|
288
|
+
} catch {
|
|
289
|
+
// body wasn't json
|
|
290
|
+
}
|
|
291
|
+
throw new Error(`postActionToConsole failed: ${reason}`);
|
|
292
|
+
}
|
|
293
|
+
const j = (await r.json()) as { ok: true; action: PostActionResult };
|
|
294
|
+
return j.action;
|
|
295
|
+
}
|