tool-call-closure 1.0.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/NOTICE.md +5 -0
- package/README.md +218 -0
- package/package.json +26 -0
- package/src/index.cjs +332 -0
- package/src/index.d.ts +56 -0
- package/src/index.js +4 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes will be documented here. This project follows Semantic Versioning.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Replacement 1.0.0 candidate: recheck generation, closure, and settlement state after caller getters, JSON serialization, and string conversion. Preserve the first terminal result during reentrant operations.
|
|
10
|
+
- Reject reentrant generation replacement and sparse call arrays before an outer registration can supersede a newer or valid existing batch.
|
|
11
|
+
- Keep fixed built-in error codes usable with small `maxCodeBytes` limits; the limit applies to explicitly supplied codes.
|
|
12
|
+
- Reject untrusted snapshot objects before reading their properties.
|
|
13
|
+
|
|
14
|
+
## 1.0.0 - 2026-10-05
|
|
15
|
+
|
|
16
|
+
- Add bounded tool-call generations, exactly-once settlement, synthetic closure provenance, stale-result fencing, and explicit OpenAI Chat Completions, OpenAI Responses, and Anthropic adapters.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Huzaifa Asif
|
|
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/NOTICE.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Notices
|
|
2
|
+
|
|
3
|
+
`tool-call-closure` is original work distributed under the MIT License.
|
|
4
|
+
|
|
5
|
+
The published runtime contains no third-party dependencies or copied third-party source. Documentation names interoperable products and links to their public documentation for comparison; those names remain the property of their respective owners.
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# tool-call-closure
|
|
2
|
+
|
|
3
|
+
`tool-call-closure` is a small, zero-dependency state machine for one awkward part of an AI agent loop: every announced tool call needs one terminal outcome, even when the run is aborted, times out, hits a limit, or is superseded.
|
|
4
|
+
|
|
5
|
+
It records calls in source order, accepts each real result once, creates explicit **error/cancelled** outcomes for calls still pending at closure, and rejects late results from old generations. It can format a completed batch for OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
Provider protocols associate each tool result with a prior tool call. Incomplete lifecycles can make a later request invalid. This is especially easy to get wrong when an `AbortSignal`, deadline, concurrency race, or request replacement intersects a batch of tools.
|
|
10
|
+
|
|
11
|
+
This package is deliberately smaller than an agent runtime. It does not choose tools, execute them, own a transcript, retry providers, or cancel side effects. It only makes terminal accounting explicit and portable.
|
|
12
|
+
|
|
13
|
+
Existing alternatives are often the better choice:
|
|
14
|
+
|
|
15
|
+
- Vercel AI SDK already models tool states and can synthesize execution-denied results during UI-message conversion. Use it when your application already uses its message/runtime abstractions.
|
|
16
|
+
- `omk-agent-core` is a full agent runtime with timeouts, abort handling, synthetic terminal results, late-settlement audit events, ordering, scheduling, and transcript integrity checks. Use it when you want that complete runtime.
|
|
17
|
+
- `conversationalist` validates tool-call/result relationships and preserves pairs while trimming transcripts. Use it for transcript validation or token-budget work.
|
|
18
|
+
- `ai-sdk-heal` repairs AI SDK histories with missing placeholders and deduplication. Use it when repairing stored AI SDK message history; this package rejects invalid runtime transitions rather than rewriting a history.
|
|
19
|
+
- Generic promise settlers such as `p-settle` settle promises, but do not track provider call IDs or emit provider payloads.
|
|
20
|
+
|
|
21
|
+
The narrow gap here is a standalone, runtime-independent primitive with no runtime dependencies. The underlying mechanism is established practice, not a novelty claim.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install tool-call-closure
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Node.js 18 or newer is required. Both ESM and CommonJS are exported.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { createToolCallClosure } from 'tool-call-closure';
|
|
35
|
+
|
|
36
|
+
const closure = createToolCallClosure();
|
|
37
|
+
const { handle } = closure.begin([
|
|
38
|
+
{ id: 'call_weather', name: 'get_weather' },
|
|
39
|
+
{ id: 'call_calendar', name: 'get_calendar' },
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
closure.succeed(handle, 'call_weather', { temperatureC: 24 });
|
|
43
|
+
|
|
44
|
+
const snapshot = closure.close(handle, {
|
|
45
|
+
status: 'cancelled',
|
|
46
|
+
code: 'request_aborted',
|
|
47
|
+
message: 'The caller disconnected before this tool completed.',
|
|
48
|
+
sideEffectState: 'unknown',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const input = closure.toOpenAIResponses(snapshot);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The calendar result is synthetic, visibly cancelled, and tagged with `provenance: "synthetic"` in the snapshot. A later real result is rejected; it never replaces the committed terminal outcome.
|
|
55
|
+
|
|
56
|
+
## API
|
|
57
|
+
|
|
58
|
+
### `createToolCallClosure(options?)`
|
|
59
|
+
|
|
60
|
+
Returns a `ToolCallClosure`. The class constructor accepts the same options.
|
|
61
|
+
|
|
62
|
+
| Option | Default | Purpose |
|
|
63
|
+
| --- | ---: | --- |
|
|
64
|
+
| `maxCalls` | `1000` | Maximum calls in one generation |
|
|
65
|
+
| `maxDiagnostics` | `100` | Maximum rejected-operation diagnostics retained |
|
|
66
|
+
| `maxResultBytes` | `1048576` | Maximum UTF-8 bytes in a reported success result |
|
|
67
|
+
| `maxMessageBytes` | `4096` | Maximum UTF-8 bytes in error/cancellation messages |
|
|
68
|
+
| `maxIdBytes` | `512` | Maximum UTF-8 bytes in a tool-call ID |
|
|
69
|
+
| `maxNameBytes` | `512` | Maximum UTF-8 bytes in a tool name |
|
|
70
|
+
| `maxCodeBytes` | `128` | Maximum UTF-8 bytes in a caller-supplied error/cancellation code |
|
|
71
|
+
|
|
72
|
+
All limits must be positive safe integers.
|
|
73
|
+
|
|
74
|
+
Built-in default codes (`error`, `cancelled`, `tool_error`, and `superseded`) stay intact even when `maxCodeBytes` is smaller; they are fixed strings of at most 10 bytes. Explicitly supplied codes, including these same strings, must fit the configured limit.
|
|
75
|
+
|
|
76
|
+
### `begin(calls)`
|
|
77
|
+
|
|
78
|
+
Validates `{ id, name }` entries, rejects duplicate IDs, and starts a generation. Starting another generation closes pending calls in the old one as `superseded` and returns that prior snapshot as `superseded`.
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
const { generation, handle, superseded } = closure.begin(calls);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Invalid new input is validated before the current generation is superseded.
|
|
85
|
+
Sparse call arrays are invalid. If a call getter starts a newer generation during validation, the outer `begin` throws without replacing that newer generation.
|
|
86
|
+
|
|
87
|
+
`handle` is an immutable, identity-checked batch handle. Keep it with the promises launched for that batch. A copied `{ generation }` object is not accepted.
|
|
88
|
+
|
|
89
|
+
Only register complete, committed, ordinary client-side function calls. Do not register streaming call fragments, approval pauses, or provider-hosted tools.
|
|
90
|
+
|
|
91
|
+
### `settle(handle, callId, outcome)`
|
|
92
|
+
|
|
93
|
+
Accepts exactly one terminal outcome:
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
closure.settle(handle, 'call_1', { status: 'success', content: { ok: true } });
|
|
97
|
+
closure.settle(handle, 'call_2', { status: 'error', code: 'upstream', message: 'Unavailable' });
|
|
98
|
+
closure.settle(handle, 'call_3', { status: 'cancelled', code: 'abort', message: 'Stopped' });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`succeed`, `fail`, and `cancel` are convenience methods. Results return `{ accepted: true, ... }` or `{ accepted: false, reason, ... }`; expected races do not throw. Rejection reasons are `stale_generation`, `run_closed`, `unknown_call`, and `duplicate_settlement`.
|
|
102
|
+
|
|
103
|
+
Success objects are JSON-stringified at settlement. Circular, undefined, or oversized values throw without changing call state. `fail(Error)` retains the message only, never the stack.
|
|
104
|
+
|
|
105
|
+
Getters, `toJSON`, and string conversion can execute caller code. State is rechecked after normalization: a nested settlement or closure wins, and a result for a superseded generation is rejected. If caller code itself changes state and then throws, that nested change is preserved. Closed snapshots remain detached and unchanged.
|
|
106
|
+
|
|
107
|
+
### `close(handle, reason?)`
|
|
108
|
+
|
|
109
|
+
Commits an `error` or `cancelled` synthetic result for every still-pending call and freezes the run. Already reported outcomes are preserved.
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
const snapshot = closure.close(handle, {
|
|
113
|
+
status: 'error',
|
|
114
|
+
code: 'tool_limit',
|
|
115
|
+
message: 'This run reached its tool-call limit.',
|
|
116
|
+
sideEffectState: 'not_started',
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`sideEffectState` is required by the semantics even though it defaults conservatively to `unknown`. Use `not_started` only when the host knows execution never began. A synthetic result never proves that an operation stopped, rolled back, or is safe to retry.
|
|
121
|
+
|
|
122
|
+
Closing is idempotent. It does **not** abort tool processes, network requests, or side effects. Wire your own `AbortSignal` into executors. If a real promise later settles or rejects, call `settle`; the typed `run_closed`/`stale_generation` result records its observed status and is the audit signal. This package does not silently promote that late value into the transcript.
|
|
123
|
+
|
|
124
|
+
### `seal(handle)`
|
|
125
|
+
|
|
126
|
+
Marks an all-reported batch complete without creating synthetic outcomes. It returns `pending_calls` if any call remains pending. Provider adapters refuse an open snapshot, so partial output is never labelled provider-ready.
|
|
127
|
+
|
|
128
|
+
### `snapshot()`
|
|
129
|
+
|
|
130
|
+
Returns an immutable detached view. Calls remain in announcement order. Each settlement carries `provenance: "reported" | "synthetic"`.
|
|
131
|
+
|
|
132
|
+
### Provider adapters
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
closure.toOpenAIChatCompletions(snapshot); // role: "tool", tool_call_id, content
|
|
136
|
+
closure.toOpenAIResponses(snapshot); // type: "function_call_output", call_id, output
|
|
137
|
+
closure.toAnthropicToolResults(snapshot); // type: "tool_result", tool_use_id, content, is_error
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Adapters require a sealed/closed snapshot and preserve announcement order. Repeated export is a pure read; it does not mean the caller should append the same results twice. Adapters do not validate a whole transcript.
|
|
141
|
+
|
|
142
|
+
For Anthropic, the caller must place the returned blocks in the user message immediately after the assistant message containing the corresponding `tool_use` blocks, with tool results before other user content. A payload-only utility cannot guarantee adjacency.
|
|
143
|
+
|
|
144
|
+
For OpenAI, choose the adapter matching the API you call; Chat Completions and Responses use different shapes.
|
|
145
|
+
|
|
146
|
+
## Common patterns
|
|
147
|
+
|
|
148
|
+
### Timeout
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
controller.abort();
|
|
153
|
+
closure.close(handle, {
|
|
154
|
+
status: 'cancelled', code: 'timeout', message: 'Tool deadline elapsed.',
|
|
155
|
+
});
|
|
156
|
+
}, 10_000);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Replacement request
|
|
160
|
+
|
|
161
|
+
Call `begin` with the new batch. The old generation is closed as `superseded`. Keep its immutable handle next to each promise and pass it to `settle`; old promises are then rejected deterministically. The returned supersession snapshot belongs to the old branch only.
|
|
162
|
+
|
|
163
|
+
### Tool-call limit
|
|
164
|
+
|
|
165
|
+
Do not invent a success. Close pending calls with an explicit error code, pass those results to the model if your application will continue, and let the model explain the limitation.
|
|
166
|
+
|
|
167
|
+
## Limits and non-goals
|
|
168
|
+
|
|
169
|
+
- No tool execution, cancellation, retry, scheduling, persistence, or side-effect rollback.
|
|
170
|
+
- No complete transcript validation or repair.
|
|
171
|
+
- No crash durability or distributed exactly-once guarantee; state exists only in one process.
|
|
172
|
+
- No guarantee that a provider accepts a request; callers own surrounding message order and API-version requirements.
|
|
173
|
+
- Results are retained in memory until a new generation replaces them; use the byte/count limits for untrusted tools.
|
|
174
|
+
- JSON serialization is ordinary `JSON.stringify`, not canonical JSON.
|
|
175
|
+
- Only text provider payloads are emitted; multimodal tool outputs need an SDK-specific adapter.
|
|
176
|
+
- Synthetic outcomes describe orchestration state, not what happened inside a late or non-cooperative tool.
|
|
177
|
+
- The host owns assistant messages, reasoning metadata, approval state, and provider-hosted tool state.
|
|
178
|
+
|
|
179
|
+
## Research basis
|
|
180
|
+
|
|
181
|
+
- [OpenAI function calling](https://developers.openai.com/api/docs/guides/function-calling) distinguishes call IDs and function-call outputs.
|
|
182
|
+
- [Anthropic parallel tool use](https://platform.claude.com/docs/en/agents-and-tools/tool-use/parallel-tool-use) documents result ordering and placement.
|
|
183
|
+
- [AI SDK missing tool results](https://ai-sdk.dev/docs/troubleshooting/missing-tool-results-error) documents the complete-result requirement.
|
|
184
|
+
- [LangChainJS #8570](https://github.com/langchain-ai/langchainjs/issues/8570) is a reported abort/missing-result failure mode; it was closed after maintainers could not reproduce it, so it is not presented as a verified outstanding defect.
|
|
185
|
+
|
|
186
|
+
LangChainJS #10090 was reviewed and intentionally excluded as supporting evidence: it already has a matching error result and concerns router continuation, which this package does not address.
|
|
187
|
+
|
|
188
|
+
## Troubleshooting
|
|
189
|
+
|
|
190
|
+
**`stale_generation`** — A newer `begin` replaced the run. Record the late result externally if needed; it was intentionally not committed.
|
|
191
|
+
|
|
192
|
+
**`run_closed`** — The generation already has terminal results. Do not send a second result for that ID.
|
|
193
|
+
|
|
194
|
+
**`unknown_call`** — The result ID was never announced in this generation. Check whether you used a provider item ID instead of its call ID.
|
|
195
|
+
|
|
196
|
+
**Provider reports a missing result** — Confirm every provider call was included in `begin`, close the batch, use the adapter for the correct OpenAI API, and place the payload in the required message position.
|
|
197
|
+
|
|
198
|
+
**A result exceeds `maxResultBytes`** — Store large output elsewhere and settle with a bounded summary or reference.
|
|
199
|
+
|
|
200
|
+
## Security
|
|
201
|
+
|
|
202
|
+
Treat tool output as untrusted data. This package serializes it but does not sanitize prompt injection, HTML, commands, or secrets. Synthetic error content contains only the code/message you provide. Avoid putting credentials or raw internal errors in those fields.
|
|
203
|
+
|
|
204
|
+
See [SECURITY.md](SECURITY.md) for reporting instructions.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
npm test
|
|
210
|
+
npm run lint
|
|
211
|
+
npm run check
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The test suite covers boundaries, fuzzed settlement order, generation races, immutability, exact provider shapes, limits, and content handling.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT © Huzaifa Asif
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tool-call-closure",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Close pending AI tool calls deterministically and fence stale results after aborts, timeouts, limits, or supersession.",
|
|
5
|
+
"keywords": ["ai", "agents", "tool-calling", "openai", "anthropic", "cancellation", "llm"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Huzaifa Asif",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/Huzaifa-Asif/tool-call-closure.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/Huzaifa-Asif/tool-call-closure/issues" },
|
|
10
|
+
"homepage": "https://github.com/Huzaifa-Asif/tool-call-closure#readme",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./src/index.cjs",
|
|
13
|
+
"module": "./src/index.js",
|
|
14
|
+
"types": "./src/index.d.ts",
|
|
15
|
+
"exports": { ".": { "types": "./src/index.d.ts", "import": "./src/index.js", "require": "./src/index.cjs" } },
|
|
16
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE.md", "CHANGELOG.md"],
|
|
17
|
+
"engines": { "node": ">=18" },
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"scripts": {
|
|
20
|
+
"lint": "node scripts/lint.mjs",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"test:coverage": "node --experimental-test-coverage --test",
|
|
23
|
+
"check": "npm run lint && npm run test:coverage && npm pack --dry-run"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": { "access": "public" }
|
|
26
|
+
}
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULTS = Object.freeze({
|
|
4
|
+
maxCalls: 1_000,
|
|
5
|
+
maxDiagnostics: 100,
|
|
6
|
+
maxResultBytes: 1_048_576,
|
|
7
|
+
maxMessageBytes: 4_096,
|
|
8
|
+
maxIdBytes: 512,
|
|
9
|
+
maxNameBytes: 512,
|
|
10
|
+
maxCodeBytes: 128,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const VALID_STATUSES = new Set(['success', 'error', 'cancelled']);
|
|
14
|
+
const CLOSE_STATUSES = new Set(['error', 'cancelled']);
|
|
15
|
+
|
|
16
|
+
function positiveInteger(value, name) {
|
|
17
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
18
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nonEmptyString(value, name) {
|
|
24
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
25
|
+
throw new TypeError(`${name} must be a non-empty string`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function boundedString(value, name, maxBytes) {
|
|
31
|
+
const text = nonEmptyString(value, name);
|
|
32
|
+
if (Buffer.byteLength(text) > maxBytes) throw new RangeError(`${name} exceeds its byte limit (${maxBytes})`);
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncateUtf8(value, maxBytes) {
|
|
37
|
+
const text = String(value);
|
|
38
|
+
const bytes = Buffer.from(text);
|
|
39
|
+
if (bytes.length <= maxBytes) return text;
|
|
40
|
+
const suffix = maxBytes >= 3 ? '...' : '';
|
|
41
|
+
let end = maxBytes - Buffer.byteLength(suffix);
|
|
42
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
|
43
|
+
return `${bytes.subarray(0, end).toString('utf8')}${suffix}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function serializeContent(value, maxBytes) {
|
|
47
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
48
|
+
if (text === undefined) throw new TypeError('result content must be JSON-serializable or a string');
|
|
49
|
+
if (Buffer.byteLength(text) > maxBytes) {
|
|
50
|
+
throw new RangeError(`result content exceeds maxResultBytes (${maxBytes})`);
|
|
51
|
+
}
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function freezeCopy(value) {
|
|
56
|
+
if (Array.isArray(value)) return Object.freeze(value.map(freezeCopy));
|
|
57
|
+
if (value && typeof value === 'object') {
|
|
58
|
+
const copy = {};
|
|
59
|
+
for (const [key, child] of Object.entries(value)) copy[key] = freezeCopy(child);
|
|
60
|
+
return Object.freeze(copy);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class ToolCallClosure {
|
|
66
|
+
#options;
|
|
67
|
+
#generation = 0;
|
|
68
|
+
#run = null;
|
|
69
|
+
#diagnostics = [];
|
|
70
|
+
#snapshots = new WeakSet();
|
|
71
|
+
|
|
72
|
+
constructor(options = {}) {
|
|
73
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
74
|
+
throw new TypeError('options must be an object');
|
|
75
|
+
}
|
|
76
|
+
this.#options = Object.freeze({
|
|
77
|
+
maxCalls: positiveInteger(options.maxCalls ?? DEFAULTS.maxCalls, 'maxCalls'),
|
|
78
|
+
maxDiagnostics: positiveInteger(options.maxDiagnostics ?? DEFAULTS.maxDiagnostics, 'maxDiagnostics'),
|
|
79
|
+
maxResultBytes: positiveInteger(options.maxResultBytes ?? DEFAULTS.maxResultBytes, 'maxResultBytes'),
|
|
80
|
+
maxMessageBytes: positiveInteger(options.maxMessageBytes ?? DEFAULTS.maxMessageBytes, 'maxMessageBytes'),
|
|
81
|
+
maxIdBytes: positiveInteger(options.maxIdBytes ?? DEFAULTS.maxIdBytes, 'maxIdBytes'),
|
|
82
|
+
maxNameBytes: positiveInteger(options.maxNameBytes ?? DEFAULTS.maxNameBytes, 'maxNameBytes'),
|
|
83
|
+
maxCodeBytes: positiveInteger(options.maxCodeBytes ?? DEFAULTS.maxCodeBytes, 'maxCodeBytes'),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get activeGeneration() {
|
|
88
|
+
return this.#run?.generation ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
begin(calls) {
|
|
92
|
+
const startingGeneration = this.#generation;
|
|
93
|
+
if (!Array.isArray(calls)) throw new TypeError('calls must be an array');
|
|
94
|
+
const count = calls.length;
|
|
95
|
+
if (!Number.isSafeInteger(count) || count < 0) throw new TypeError('calls.length must be a non-negative safe integer');
|
|
96
|
+
if (count > this.#options.maxCalls) {
|
|
97
|
+
throw new RangeError(`calls exceed maxCalls (${this.#options.maxCalls})`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const normalized = [];
|
|
102
|
+
for (let index = 0; index < count; index += 1) {
|
|
103
|
+
const call = calls[index];
|
|
104
|
+
if (!call || typeof call !== 'object' || Array.isArray(call)) {
|
|
105
|
+
throw new TypeError(`calls[${index}] must be an object`);
|
|
106
|
+
}
|
|
107
|
+
const id = boundedString(call.id, `calls[${index}].id`, this.#options.maxIdBytes);
|
|
108
|
+
if (seen.has(id)) throw new TypeError(`duplicate tool call id: ${id}`);
|
|
109
|
+
seen.add(id);
|
|
110
|
+
normalized.push({
|
|
111
|
+
id,
|
|
112
|
+
name: boundedString(call.name, `calls[${index}].name`, this.#options.maxNameBytes),
|
|
113
|
+
state: 'pending',
|
|
114
|
+
settlement: null,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (this.#generation !== startingGeneration) {
|
|
118
|
+
throw new TypeError('active generation changed while validating calls');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let superseded = null;
|
|
122
|
+
if (this.#run && !this.#run.closed) {
|
|
123
|
+
superseded = this.#commitClose(this.#run, {
|
|
124
|
+
status: 'cancelled',
|
|
125
|
+
code: 'superseded',
|
|
126
|
+
message: truncateUtf8('Tool call was superseded by a newer generation.', this.#options.maxMessageBytes),
|
|
127
|
+
sideEffectState: 'unknown',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.#generation += 1;
|
|
132
|
+
const handle = Object.freeze({ generation: this.#generation });
|
|
133
|
+
this.#diagnostics = [];
|
|
134
|
+
this.#run = {
|
|
135
|
+
generation: this.#generation,
|
|
136
|
+
handle,
|
|
137
|
+
closed: false,
|
|
138
|
+
closeReason: null,
|
|
139
|
+
calls: normalized,
|
|
140
|
+
byId: new Map(normalized.map((call) => [call.id, call])),
|
|
141
|
+
};
|
|
142
|
+
return Object.freeze({ generation: this.#generation, handle, superseded });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
settle(handle, callId, outcome) {
|
|
146
|
+
return this.#settle(handle, callId, outcome);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#settle(handle, callId, outcome, defaultCode) {
|
|
150
|
+
boundedString(callId, 'callId', this.#options.maxIdBytes);
|
|
151
|
+
const candidateGeneration = handle?.generation;
|
|
152
|
+
const generation = Number.isSafeInteger(candidateGeneration) && candidateGeneration > 0 ? candidateGeneration : null;
|
|
153
|
+
const candidateStatus = outcome?.status;
|
|
154
|
+
const observedStatus = VALID_STATUSES.has(candidateStatus) ? candidateStatus : (candidateStatus == null ? null : 'invalid');
|
|
155
|
+
|
|
156
|
+
if (!this.#run || handle !== this.#run.handle) {
|
|
157
|
+
return this.#reject('stale_generation', callId, generation, observedStatus);
|
|
158
|
+
}
|
|
159
|
+
if (this.#run.closed) return this.#reject('run_closed', callId, generation, observedStatus);
|
|
160
|
+
|
|
161
|
+
const call = this.#run.byId.get(callId);
|
|
162
|
+
if (!call) return this.#reject('unknown_call', callId, generation);
|
|
163
|
+
if (call.state !== 'pending') return this.#reject('duplicate_settlement', callId, generation);
|
|
164
|
+
|
|
165
|
+
const settlement = this.#normalizeOutcome(outcome, defaultCode);
|
|
166
|
+
// JSON serialization, getters, and string conversion can run caller code.
|
|
167
|
+
// A nested terminal write or newer generation wins over this operation.
|
|
168
|
+
if (handle !== this.#run.handle) return this.#reject('stale_generation', callId, generation, settlement.status);
|
|
169
|
+
if (this.#run.closed) return this.#reject('run_closed', callId, generation, settlement.status);
|
|
170
|
+
if (call.state !== 'pending') return this.#reject('duplicate_settlement', callId, generation, settlement.status);
|
|
171
|
+
call.state = settlement.status;
|
|
172
|
+
call.settlement = settlement;
|
|
173
|
+
return freezeCopy({ accepted: true, generation, callId, settlement });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
succeed(handle, callId, content) {
|
|
177
|
+
return this.settle(handle, callId, { status: 'success', content });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fail(handle, callId, error, code) {
|
|
181
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
182
|
+
return this.#settle(handle, callId, { status: 'error', code, message }, 'tool_error');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
cancel(handle, callId, message = 'Tool call was cancelled.', code) {
|
|
186
|
+
return this.#settle(handle, callId, { status: 'cancelled', code, message }, 'cancelled');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
close(handle, reason = {}) {
|
|
190
|
+
const candidateGeneration = handle?.generation;
|
|
191
|
+
const generation = Number.isSafeInteger(candidateGeneration) && candidateGeneration > 0 ? candidateGeneration : null;
|
|
192
|
+
if (!this.#run || handle !== this.#run.handle) {
|
|
193
|
+
return this.#reject('stale_generation', null, generation, null);
|
|
194
|
+
}
|
|
195
|
+
if (this.#run.closed) return this.snapshot();
|
|
196
|
+
const run = this.#run;
|
|
197
|
+
|
|
198
|
+
const status = reason.status ?? 'cancelled';
|
|
199
|
+
if (!CLOSE_STATUSES.has(status)) {
|
|
200
|
+
throw new TypeError('close status must be error or cancelled');
|
|
201
|
+
}
|
|
202
|
+
const suppliedCode = reason.code;
|
|
203
|
+
const code = suppliedCode == null ? status : boundedString(suppliedCode, 'reason.code', this.#options.maxCodeBytes);
|
|
204
|
+
const sideEffectState = reason.sideEffectState ?? 'unknown';
|
|
205
|
+
if (sideEffectState !== 'unknown' && sideEffectState !== 'not_started') {
|
|
206
|
+
throw new TypeError('reason.sideEffectState must be unknown or not_started');
|
|
207
|
+
}
|
|
208
|
+
const message = truncateUtf8(
|
|
209
|
+
reason.message ?? (status === 'error' ? 'Tool call did not complete.' : 'Tool call was cancelled.'),
|
|
210
|
+
this.#options.maxMessageBytes,
|
|
211
|
+
);
|
|
212
|
+
if (this.#run !== run) return this.#reject('stale_generation', null, generation, null);
|
|
213
|
+
if (run.closed) return this.snapshot();
|
|
214
|
+
return this.#commitClose(run, { status, code, message, sideEffectState });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#commitClose(run, { status, code, message, sideEffectState }) {
|
|
218
|
+
for (const call of run.calls) {
|
|
219
|
+
if (call.state === 'pending') {
|
|
220
|
+
call.state = status;
|
|
221
|
+
call.settlement = Object.freeze({ status, code, message, provenance: 'synthetic', sideEffectState });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
run.closed = true;
|
|
225
|
+
run.closeReason = { status, code, message, sideEffectState };
|
|
226
|
+
return this.snapshot();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
seal(handle) {
|
|
230
|
+
const candidateGeneration = handle?.generation;
|
|
231
|
+
const generation = Number.isSafeInteger(candidateGeneration) && candidateGeneration > 0 ? candidateGeneration : null;
|
|
232
|
+
if (!this.#run || handle !== this.#run.handle) {
|
|
233
|
+
return this.#reject('stale_generation', null, generation, null);
|
|
234
|
+
}
|
|
235
|
+
if (this.#run.closed) return this.snapshot();
|
|
236
|
+
if (this.#run.calls.some((call) => call.state === 'pending')) {
|
|
237
|
+
return this.#reject('pending_calls', null, generation, null);
|
|
238
|
+
}
|
|
239
|
+
this.#run.closed = true;
|
|
240
|
+
this.#run.closeReason = { status: 'complete' };
|
|
241
|
+
return this.snapshot();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
snapshot() {
|
|
245
|
+
if (!this.#run) return null;
|
|
246
|
+
const snapshot = freezeCopy({
|
|
247
|
+
generation: this.#run.generation,
|
|
248
|
+
closed: this.#run.closed,
|
|
249
|
+
closeReason: this.#run.closeReason,
|
|
250
|
+
calls: this.#run.calls.map(({ id, name, state, settlement }) => ({ id, name, state, settlement })),
|
|
251
|
+
diagnostics: this.#diagnostics,
|
|
252
|
+
});
|
|
253
|
+
this.#snapshots.add(snapshot);
|
|
254
|
+
return snapshot;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
toOpenAIChatCompletions(snapshot = this.snapshot()) {
|
|
258
|
+
this.#assertSnapshot(snapshot);
|
|
259
|
+
return snapshot.calls.filter((call) => call.state !== 'pending').map((call) => ({
|
|
260
|
+
role: 'tool',
|
|
261
|
+
tool_call_id: call.id,
|
|
262
|
+
content: this.#adapterContent(call.settlement),
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
toOpenAIResponses(snapshot = this.snapshot()) {
|
|
267
|
+
this.#assertSnapshot(snapshot);
|
|
268
|
+
return snapshot.calls.filter((call) => call.state !== 'pending').map((call) => ({
|
|
269
|
+
type: 'function_call_output',
|
|
270
|
+
call_id: call.id,
|
|
271
|
+
output: this.#adapterContent(call.settlement),
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
toAnthropicToolResults(snapshot = this.snapshot()) {
|
|
276
|
+
this.#assertSnapshot(snapshot);
|
|
277
|
+
return snapshot.calls.filter((call) => call.state !== 'pending').map((call) => ({
|
|
278
|
+
type: 'tool_result',
|
|
279
|
+
tool_use_id: call.id,
|
|
280
|
+
content: this.#adapterContent(call.settlement),
|
|
281
|
+
...(call.state === 'success' ? {} : { is_error: true }),
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#normalizeOutcome(outcome, defaultCode) {
|
|
286
|
+
if (!outcome || typeof outcome !== 'object' || Array.isArray(outcome)) {
|
|
287
|
+
throw new TypeError('outcome must be an object');
|
|
288
|
+
}
|
|
289
|
+
const status = outcome.status;
|
|
290
|
+
if (!VALID_STATUSES.has(status)) {
|
|
291
|
+
throw new TypeError('outcome.status must be success, error, or cancelled');
|
|
292
|
+
}
|
|
293
|
+
if (status === 'success') {
|
|
294
|
+
return Object.freeze({ status, content: serializeContent(outcome.content, this.#options.maxResultBytes), provenance: 'reported' });
|
|
295
|
+
}
|
|
296
|
+
const suppliedCode = outcome.code;
|
|
297
|
+
return Object.freeze({
|
|
298
|
+
status,
|
|
299
|
+
code: suppliedCode == null ? (defaultCode ?? status) : boundedString(suppliedCode, 'outcome.code', this.#options.maxCodeBytes),
|
|
300
|
+
message: truncateUtf8(outcome.message ?? (status === 'error' ? 'Tool call failed.' : 'Tool call was cancelled.'), this.#options.maxMessageBytes),
|
|
301
|
+
provenance: 'reported',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#adapterContent(settlement) {
|
|
306
|
+
if (settlement.status === 'success') return settlement.content;
|
|
307
|
+
return JSON.stringify({ error: {
|
|
308
|
+
code: settlement.code,
|
|
309
|
+
message: settlement.message,
|
|
310
|
+
...(settlement.provenance === 'synthetic' ? { side_effect_state: settlement.sideEffectState } : {}),
|
|
311
|
+
} });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#assertSnapshot(snapshot) {
|
|
315
|
+
if (!snapshot || typeof snapshot !== 'object' || !this.#snapshots.has(snapshot) || !Array.isArray(snapshot.calls)) {
|
|
316
|
+
throw new TypeError('snapshot must be a ToolCallClosure snapshot');
|
|
317
|
+
}
|
|
318
|
+
if (!snapshot.closed) throw new TypeError('snapshot must be sealed before provider export');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#reject(reason, callId, generation, observedStatus = null) {
|
|
322
|
+
const diagnostic = freezeCopy({ reason, callId, generation, observedStatus });
|
|
323
|
+
if (this.#diagnostics.length < this.#options.maxDiagnostics) this.#diagnostics.push(diagnostic);
|
|
324
|
+
return freezeCopy({ accepted: false, reason, callId, generation, observedStatus });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function createToolCallClosure(options) {
|
|
329
|
+
return new ToolCallClosure(options);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = { ToolCallClosure, createToolCallClosure };
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type ToolCall = Readonly<{ id: string; name: string }>;
|
|
2
|
+
export type Settlement =
|
|
3
|
+
| Readonly<{ status: 'success'; content: string; provenance: 'reported' }>
|
|
4
|
+
| Readonly<{ status: 'error' | 'cancelled'; code: string; message: string; provenance: 'reported' }>
|
|
5
|
+
| Readonly<{ status: 'error' | 'cancelled'; code: string; message: string; provenance: 'synthetic'; sideEffectState: 'unknown' | 'not_started' }>;
|
|
6
|
+
export type BatchHandle = Readonly<{ generation: number }>;
|
|
7
|
+
export type CallSnapshot = Readonly<{
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
state: 'pending' | 'success' | 'error' | 'cancelled';
|
|
11
|
+
settlement: Settlement | null;
|
|
12
|
+
}>;
|
|
13
|
+
export type DiagnosticReason = 'stale_generation' | 'run_closed' | 'unknown_call' | 'duplicate_settlement' | 'pending_calls';
|
|
14
|
+
export type Snapshot = Readonly<{
|
|
15
|
+
generation: number;
|
|
16
|
+
closed: boolean;
|
|
17
|
+
closeReason: null | Readonly<{ status: 'complete' }> | Readonly<{ status: 'error' | 'cancelled'; code: string; message: string; sideEffectState: 'unknown' | 'not_started' }>;
|
|
18
|
+
calls: readonly CallSnapshot[];
|
|
19
|
+
diagnostics: readonly Readonly<{ reason: DiagnosticReason; callId: string | null; generation: number | null; observedStatus: string | null }>[];
|
|
20
|
+
}>;
|
|
21
|
+
export type SettlementResult =
|
|
22
|
+
| Readonly<{ accepted: true; generation: number; callId: string; settlement: Settlement }>
|
|
23
|
+
| Readonly<{ accepted: false; reason: DiagnosticReason; callId: string | null; generation: number | null; observedStatus: string | null }>;
|
|
24
|
+
export interface ToolCallClosureOptions {
|
|
25
|
+
maxCalls?: number;
|
|
26
|
+
maxDiagnostics?: number;
|
|
27
|
+
maxResultBytes?: number;
|
|
28
|
+
maxMessageBytes?: number;
|
|
29
|
+
maxIdBytes?: number;
|
|
30
|
+
maxNameBytes?: number;
|
|
31
|
+
maxCodeBytes?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface Outcome {
|
|
34
|
+
status: 'success' | 'error' | 'cancelled';
|
|
35
|
+
content?: unknown;
|
|
36
|
+
code?: string;
|
|
37
|
+
message?: string;
|
|
38
|
+
}
|
|
39
|
+
export declare class ToolCallClosure {
|
|
40
|
+
constructor(options?: ToolCallClosureOptions);
|
|
41
|
+
get activeGeneration(): number | null;
|
|
42
|
+
begin(calls: readonly ToolCall[]): Readonly<{ generation: number; handle: BatchHandle; superseded: Snapshot | null }>;
|
|
43
|
+
settle(handle: BatchHandle, callId: string, outcome: Outcome): SettlementResult;
|
|
44
|
+
succeed(handle: BatchHandle, callId: string, content: unknown): SettlementResult;
|
|
45
|
+
fail(handle: BatchHandle, callId: string, error: unknown, code?: string): SettlementResult;
|
|
46
|
+
cancel(handle: BatchHandle, callId: string, message?: string, code?: string): SettlementResult;
|
|
47
|
+
close(handle: BatchHandle, reason?: { status?: 'error' | 'cancelled'; code?: string; message?: string; sideEffectState?: 'unknown' | 'not_started' }): Snapshot | SettlementResult;
|
|
48
|
+
seal(handle: BatchHandle): Snapshot | SettlementResult;
|
|
49
|
+
snapshot(): Snapshot | null;
|
|
50
|
+
toOpenAIChatCompletions(snapshot?: Snapshot): readonly Readonly<{ role: 'tool'; tool_call_id: string; content: string }>[];
|
|
51
|
+
toOpenAIResponses(snapshot?: Snapshot): readonly Readonly<{ type: 'function_call_output'; call_id: string; output: string }>[];
|
|
52
|
+
toAnthropicToolResults(snapshot?: Snapshot): readonly Readonly<{ type: 'tool_result'; tool_use_id: string; content: string; is_error?: true }>[];
|
|
53
|
+
}
|
|
54
|
+
export declare function createToolCallClosure(options?: ToolCallClosureOptions): ToolCallClosure;
|
|
55
|
+
declare const api: Readonly<{ ToolCallClosure: typeof ToolCallClosure; createToolCallClosure: typeof createToolCallClosure }>;
|
|
56
|
+
export default api;
|
package/src/index.js
ADDED