replyonce 0.0.0-stage → 0.1.1
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 +202 -0
- package/NOTICE +7 -0
- package/README.md +330 -2
- package/dist/adapters.cjs +260 -0
- package/dist/adapters.cjs.map +1 -0
- package/dist/adapters.d.cts +134 -0
- package/dist/adapters.d.ts +134 -0
- package/dist/adapters.js +224 -0
- package/dist/adapters.js.map +1 -0
- package/dist/cli.js +1048 -0
- package/dist/index.cjs +841 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +803 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.cjs +146 -0
- package/dist/postgres.cjs.map +1 -0
- package/dist/postgres.d.cts +48 -0
- package/dist/postgres.d.ts +48 -0
- package/dist/postgres.js +120 -0
- package/dist/postgres.js.map +1 -0
- package/dist/redis.cjs +85 -0
- package/dist/redis.cjs.map +1 -0
- package/dist/redis.d.cts +40 -0
- package/dist/redis.d.ts +40 -0
- package/dist/redis.js +59 -0
- package/dist/redis.js.map +1 -0
- package/dist/replyonce-C-Qhsfsw.d.cts +212 -0
- package/dist/replyonce-C9AavZdk.d.ts +212 -0
- package/dist/types-Dn7TSEnu.d.cts +209 -0
- package/dist/types-Dn7TSEnu.d.ts +209 -0
- package/package.json +107 -4
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/adapters/index.ts
|
|
21
|
+
var adapters_exports = {};
|
|
22
|
+
__export(adapters_exports, {
|
|
23
|
+
ScopeBusyError: () => ScopeBusyError,
|
|
24
|
+
checkTool: () => checkTool,
|
|
25
|
+
describeOutcome: () => describeOutcome,
|
|
26
|
+
dispatchTool: () => dispatchTool,
|
|
27
|
+
guardTool: () => guardTool,
|
|
28
|
+
httpStatusFor: () => httpStatusFor,
|
|
29
|
+
onceProcessor: () => onceProcessor,
|
|
30
|
+
toAnthropicTool: () => toAnthropicTool,
|
|
31
|
+
toOpenAITool: () => toOpenAITool,
|
|
32
|
+
webhookOnce: () => webhookOnce
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(adapters_exports);
|
|
35
|
+
|
|
36
|
+
// src/adapters/tools.ts
|
|
37
|
+
function guardTool(spec) {
|
|
38
|
+
const intent = spec.intent ?? spec.name;
|
|
39
|
+
return {
|
|
40
|
+
name: spec.name,
|
|
41
|
+
description: spec.description,
|
|
42
|
+
parameters: spec.parameters,
|
|
43
|
+
intent,
|
|
44
|
+
identity: spec.identity,
|
|
45
|
+
async call(run, args) {
|
|
46
|
+
const result = await run.once(intent, spec.identity(args), (ctx) => spec.execute(args, ctx), {
|
|
47
|
+
...spec.summarize ? { summary: spec.summarize(args) } : {},
|
|
48
|
+
...spec.conflict ? { conflict: spec.conflict } : {}
|
|
49
|
+
});
|
|
50
|
+
return describeOutcome(result);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function describeOutcome(result) {
|
|
55
|
+
switch (result.status) {
|
|
56
|
+
case "executed":
|
|
57
|
+
return { ok: true, status: "executed", terminal: false, retryable: false, message: "Done.", output: result.value };
|
|
58
|
+
case "replayed":
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
status: "replayed",
|
|
62
|
+
terminal: false,
|
|
63
|
+
retryable: false,
|
|
64
|
+
message: "This exact action was already done earlier. It was not repeated.",
|
|
65
|
+
output: result.value
|
|
66
|
+
};
|
|
67
|
+
case "in_flight":
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
status: "in_flight",
|
|
71
|
+
terminal: false,
|
|
72
|
+
retryable: false,
|
|
73
|
+
message: "This exact action is already in progress elsewhere. Do not repeat it."
|
|
74
|
+
};
|
|
75
|
+
case "ambiguous":
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
status: "ambiguous",
|
|
79
|
+
terminal: false,
|
|
80
|
+
retryable: false,
|
|
81
|
+
message: "This action may or may not have happened (the outcome is unknown). Do not repeat it. Tell the user it is being checked, or escalate to a human."
|
|
82
|
+
};
|
|
83
|
+
case "failed":
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
status: "failed",
|
|
87
|
+
terminal: false,
|
|
88
|
+
retryable: result.retryable,
|
|
89
|
+
message: result.retryable ? `The action failed and did not happen: ${errorText(result.error)}. You may retry.` : `The action failed and did not happen: ${errorText(result.error)}. Retry limit reached; escalate.`
|
|
90
|
+
};
|
|
91
|
+
case "refused": {
|
|
92
|
+
switch (result.reason) {
|
|
93
|
+
case "conversation_changed":
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
status: "refused",
|
|
97
|
+
terminal: false,
|
|
98
|
+
retryable: true,
|
|
99
|
+
message: "Not done: the conversation changed since you last read it. Another run did the following. Re-read the conversation and decide whether your action is still needed; if it is, call the tool again.",
|
|
100
|
+
conflicts: (result.conflicts ?? []).map((c) => ({ intent: c.intent, status: c.status, summary: c.summary }))
|
|
101
|
+
};
|
|
102
|
+
case "exhausted":
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
status: "refused",
|
|
106
|
+
terminal: false,
|
|
107
|
+
retryable: false,
|
|
108
|
+
message: "Not done: this action already failed too many times. Escalate to a human."
|
|
109
|
+
};
|
|
110
|
+
default:
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
status: "refused",
|
|
114
|
+
terminal: true,
|
|
115
|
+
retryable: false,
|
|
116
|
+
message: `Stop: this run may no longer act on this conversation (${result.reason}).`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function checkTool(tools, name = "check_already_done") {
|
|
123
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
description: "Check whether a side-effecting action was already done on this conversation before doing it again. Pass the tool name and the arguments you would call it with. Omit arguments to list every recorded call of that tool.",
|
|
127
|
+
parameters: {
|
|
128
|
+
type: "object",
|
|
129
|
+
properties: {
|
|
130
|
+
tool: { type: "string", enum: tools.map((t) => t.name) },
|
|
131
|
+
arguments: { type: "object", description: "The arguments you would pass to that tool." }
|
|
132
|
+
},
|
|
133
|
+
required: ["tool"],
|
|
134
|
+
additionalProperties: false
|
|
135
|
+
},
|
|
136
|
+
intent: name,
|
|
137
|
+
identity: (args) => args,
|
|
138
|
+
async call(run, args) {
|
|
139
|
+
const tool = byName.get(args?.tool);
|
|
140
|
+
if (!tool) {
|
|
141
|
+
return { ok: false, status: "failed", terminal: false, retryable: false, message: `Unknown tool ${String(args?.tool)}.` };
|
|
142
|
+
}
|
|
143
|
+
if (args.arguments !== void 0) {
|
|
144
|
+
const found = await run.check(tool.intent, tool.identity(args.arguments));
|
|
145
|
+
const message = found.status === "none" ? "Not done yet." : found.status === "committed" ? "Already done. Do not repeat it." : found.status === "failed" ? "Tried before and it did not happen. It may be retried." : found.status === "pending" ? "In progress right now. Do not repeat it." : "Outcome unknown. Do not repeat it; escalate.";
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
status: "executed",
|
|
149
|
+
terminal: false,
|
|
150
|
+
retryable: false,
|
|
151
|
+
message,
|
|
152
|
+
output: { status: found.status, done: found.done, summary: found.entry?.summary ?? null }
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const all = (await run.history()).filter((e) => e.intent === tool.intent);
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
status: "executed",
|
|
159
|
+
terminal: false,
|
|
160
|
+
retryable: false,
|
|
161
|
+
message: all.length === 0 ? "No calls recorded." : `${all.length} call(s) recorded.`,
|
|
162
|
+
output: all.map((e) => ({ status: e.status, summary: e.summary, at: new Date(e.settledAt ?? e.claimedAt).toISOString() }))
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
async function dispatchTool(run, tools, name, args) {
|
|
168
|
+
const tool = tools.find((t) => t.name === name);
|
|
169
|
+
if (!tool) {
|
|
170
|
+
return { ok: false, status: "failed", terminal: false, retryable: false, message: `Unknown tool ${name}.` };
|
|
171
|
+
}
|
|
172
|
+
return tool.call(run, args);
|
|
173
|
+
}
|
|
174
|
+
function toOpenAITool(tool, api = "chat") {
|
|
175
|
+
if (api === "responses") {
|
|
176
|
+
return { type: "function", name: tool.name, description: tool.description, parameters: tool.parameters };
|
|
177
|
+
}
|
|
178
|
+
return { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } };
|
|
179
|
+
}
|
|
180
|
+
function toAnthropicTool(tool) {
|
|
181
|
+
return { name: tool.name, description: tool.description, input_schema: tool.parameters };
|
|
182
|
+
}
|
|
183
|
+
function errorText(err) {
|
|
184
|
+
return (err instanceof Error ? err.message : String(err)).slice(0, 300);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/adapters/webhook.ts
|
|
188
|
+
function httpStatusFor(result) {
|
|
189
|
+
switch (result.status) {
|
|
190
|
+
case "executed":
|
|
191
|
+
case "replayed":
|
|
192
|
+
return { status: 200 };
|
|
193
|
+
case "in_flight":
|
|
194
|
+
return { status: 503, retryAfter: 5 };
|
|
195
|
+
case "failed":
|
|
196
|
+
return result.retryable ? { status: 500 } : { status: 200 };
|
|
197
|
+
default:
|
|
198
|
+
return { status: 200 };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function webhookOnce(ro, input, handler) {
|
|
202
|
+
const { scope, eventId, intent = "webhook", ...options } = input;
|
|
203
|
+
const result = await ro.once({ scope, intent, payload: { eventId } }, handler, options);
|
|
204
|
+
return { result, ...httpStatusFor(result) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/adapters/bullmq.ts
|
|
208
|
+
var ScopeBusyError = class extends Error {
|
|
209
|
+
holderRunId;
|
|
210
|
+
constructor(scope, holderRunId) {
|
|
211
|
+
super(`replyonce: scope ${JSON.stringify(scope)} is held by run ${holderRunId}`);
|
|
212
|
+
this.name = "ScopeBusyError";
|
|
213
|
+
this.holderRunId = holderRunId;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
function onceProcessor(ro, options, processor) {
|
|
217
|
+
return async (job) => {
|
|
218
|
+
const scope = options.scope(job);
|
|
219
|
+
if (!options.runId && !job.id) throw new TypeError("replyonce: job has no id; pass options.runId");
|
|
220
|
+
const runId = options.runId ? options.runId(job) : job.queueName ? `${job.queueName}:${job.id}` : job.id;
|
|
221
|
+
const run = await ro.begin(scope, { runId, ...options.mode ? { mode: options.mode } : {} });
|
|
222
|
+
const skip = (reason) => {
|
|
223
|
+
options.onSkip?.(job, reason);
|
|
224
|
+
return { replyonce: "skipped", reason };
|
|
225
|
+
};
|
|
226
|
+
if (run.state === "busy") throw new ScopeBusyError(scope, run.busyHolder.runId);
|
|
227
|
+
if (run.state === "ended") return skip("ended");
|
|
228
|
+
if (run.state === "superseded") {
|
|
229
|
+
await run.end("cancelled");
|
|
230
|
+
return skip("superseded");
|
|
231
|
+
}
|
|
232
|
+
if (run.state === "resumed" && run.alreadyWrote && options.onRedeliveredAfterWrite === "skip") {
|
|
233
|
+
await run.end("failed");
|
|
234
|
+
return skip("redelivered_after_write");
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const out = await processor(job, run);
|
|
238
|
+
await run.end("completed");
|
|
239
|
+
return out;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const attempts = job.opts?.attempts ?? 1;
|
|
242
|
+
if (job.attemptsMade + 1 >= attempts) await run.end("failed").catch(() => void 0);
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
248
|
+
0 && (module.exports = {
|
|
249
|
+
ScopeBusyError,
|
|
250
|
+
checkTool,
|
|
251
|
+
describeOutcome,
|
|
252
|
+
dispatchTool,
|
|
253
|
+
guardTool,
|
|
254
|
+
httpStatusFor,
|
|
255
|
+
onceProcessor,
|
|
256
|
+
toAnthropicTool,
|
|
257
|
+
toOpenAITool,
|
|
258
|
+
webhookOnce
|
|
259
|
+
});
|
|
260
|
+
//# sourceMappingURL=adapters.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapters/index.ts","../src/adapters/tools.ts","../src/adapters/webhook.ts","../src/adapters/bullmq.ts"],"sourcesContent":["export {\n checkTool,\n describeOutcome,\n dispatchTool,\n guardTool,\n toAnthropicTool,\n toOpenAITool,\n} from './tools.js';\nexport type { GuardedTool, GuardedToolSpec, JsonSchema, ToolDefinition, ToolOutcome } from './tools.js';\nexport { httpStatusFor, webhookOnce } from './webhook.js';\nexport type { WebhookOnceInput, WebhookOnceResponse } from './webhook.js';\nexport { ScopeBusyError, onceProcessor } from './bullmq.js';\nexport type { JobLike, OnceProcessorOptions, SkipReason, Skipped } from './bullmq.js';\n","import type { ConflictMode } from '../logic.js';\nimport type { Run } from '../replyonce.js';\nimport type { ConflictInfo, EffectContext, OnceResult } from '../types.js';\n\nexport type JsonSchema = Record<string, unknown>;\n\nexport interface ToolDefinition {\n name: string;\n description: string;\n parameters: JsonSchema;\n}\n\nexport interface GuardedToolSpec<A, R> extends ToolDefinition {\n /** Ledger intent. Defaults to the tool name. */\n intent?: string;\n /**\n * The business identity of the action, from its arguments: what makes two calls \"the\n * same action\". For a reply, the inbound message being answered; for a refund, the\n * order and amount. Never the model's tool-call id.\n */\n identity: (args: A) => unknown;\n /** Short text other runs see if this write conflicts with theirs. */\n summarize?: (args: A) => string;\n conflict?: ConflictMode;\n execute: (args: A, ctx: EffectContext) => Promise<R> | R;\n}\n\n/** What to hand back to the model. `terminal` means the run should stop. */\nexport interface ToolOutcome {\n ok: boolean;\n status: OnceResult<unknown>['status'];\n terminal: boolean;\n retryable: boolean;\n message: string;\n output?: unknown;\n conflicts?: Array<Pick<ConflictInfo, 'intent' | 'status' | 'summary'>>;\n}\n\nexport interface GuardedTool<A = any, R = unknown> extends ToolDefinition {\n intent: string;\n identity: (args: A) => unknown;\n call(run: Run, args: A): Promise<ToolOutcome>;\n}\n\n/** Wrap a side-effecting tool so a run executes it at most once per business identity. */\nexport function guardTool<A, R>(spec: GuardedToolSpec<A, R>): GuardedTool<A, R> {\n const intent = spec.intent ?? spec.name;\n return {\n name: spec.name,\n description: spec.description,\n parameters: spec.parameters,\n intent,\n identity: spec.identity,\n async call(run, args) {\n const result = await run.once(intent, spec.identity(args), (ctx) => spec.execute(args, ctx), {\n ...(spec.summarize ? { summary: spec.summarize(args) } : {}),\n ...(spec.conflict ? { conflict: spec.conflict } : {}),\n });\n return describeOutcome(result);\n },\n };\n}\n\n/** Turn a `once` result into a model-friendly tool result. */\nexport function describeOutcome(result: OnceResult<unknown>): ToolOutcome {\n switch (result.status) {\n case 'executed':\n return { ok: true, status: 'executed', terminal: false, retryable: false, message: 'Done.', output: result.value };\n case 'replayed':\n return {\n ok: true,\n status: 'replayed',\n terminal: false,\n retryable: false,\n message: 'This exact action was already done earlier. It was not repeated.',\n output: result.value,\n };\n case 'in_flight':\n return {\n ok: false,\n status: 'in_flight',\n terminal: false,\n retryable: false,\n message: 'This exact action is already in progress elsewhere. Do not repeat it.',\n };\n case 'ambiguous':\n return {\n ok: false,\n status: 'ambiguous',\n terminal: false,\n retryable: false,\n message:\n 'This action may or may not have happened (the outcome is unknown). Do not repeat it. ' +\n 'Tell the user it is being checked, or escalate to a human.',\n };\n case 'failed':\n return {\n ok: false,\n status: 'failed',\n terminal: false,\n retryable: result.retryable,\n message: result.retryable\n ? `The action failed and did not happen: ${errorText(result.error)}. You may retry.`\n : `The action failed and did not happen: ${errorText(result.error)}. Retry limit reached; escalate.`,\n };\n case 'refused': {\n switch (result.reason) {\n case 'conversation_changed':\n return {\n ok: false,\n status: 'refused',\n terminal: false,\n retryable: true,\n message:\n 'Not done: the conversation changed since you last read it. Another run did the following. ' +\n 'Re-read the conversation and decide whether your action is still needed; if it is, call the tool again.',\n conflicts: (result.conflicts ?? []).map((c) => ({ intent: c.intent, status: c.status, summary: c.summary })),\n };\n case 'exhausted':\n return {\n ok: false,\n status: 'refused',\n terminal: false,\n retryable: false,\n message: 'Not done: this action already failed too many times. Escalate to a human.',\n };\n default:\n return {\n ok: false,\n status: 'refused',\n terminal: true,\n retryable: false,\n message: `Stop: this run may no longer act on this conversation (${result.reason}).`,\n };\n }\n }\n }\n}\n\n/**\n * A read-only \"was this already done?\" tool. Given a guarded tool name and, optionally,\n * its arguments, it reports what the ledger knows. Add it alongside your write tools.\n */\nexport function checkTool(tools: GuardedTool[], name = 'check_already_done'): GuardedTool<{ tool: string; arguments?: unknown }, unknown> {\n const byName = new Map(tools.map((t) => [t.name, t]));\n return {\n name,\n description:\n 'Check whether a side-effecting action was already done on this conversation before doing it again. ' +\n 'Pass the tool name and the arguments you would call it with. Omit arguments to list every recorded call of that tool.',\n parameters: {\n type: 'object',\n properties: {\n tool: { type: 'string', enum: tools.map((t) => t.name) },\n arguments: { type: 'object', description: 'The arguments you would pass to that tool.' },\n },\n required: ['tool'],\n additionalProperties: false,\n },\n intent: name,\n identity: (args) => args,\n async call(run, args) {\n const tool = byName.get(args?.tool);\n if (!tool) {\n return { ok: false, status: 'failed', terminal: false, retryable: false, message: `Unknown tool ${String(args?.tool)}.` };\n }\n if (args.arguments !== undefined) {\n const found = await run.check(tool.intent, tool.identity(args.arguments));\n const message =\n found.status === 'none'\n ? 'Not done yet.'\n : found.status === 'committed'\n ? 'Already done. Do not repeat it.'\n : found.status === 'failed'\n ? 'Tried before and it did not happen. It may be retried.'\n : found.status === 'pending'\n ? 'In progress right now. Do not repeat it.'\n : 'Outcome unknown. Do not repeat it; escalate.';\n return {\n ok: true,\n status: 'executed',\n terminal: false,\n retryable: false,\n message,\n output: { status: found.status, done: found.done, summary: found.entry?.summary ?? null },\n };\n }\n const all = (await run.history()).filter((e) => e.intent === tool.intent);\n return {\n ok: true,\n status: 'executed',\n terminal: false,\n retryable: false,\n message: all.length === 0 ? 'No calls recorded.' : `${all.length} call(s) recorded.`,\n output: all.map((e) => ({ status: e.status, summary: e.summary, at: new Date(e.settledAt ?? e.claimedAt).toISOString() })),\n };\n },\n };\n}\n\n/** Route a model's tool call to the matching guarded tool. */\nexport async function dispatchTool(run: Run, tools: GuardedTool[], name: string, args: unknown): Promise<ToolOutcome> {\n const tool = tools.find((t) => t.name === name);\n if (!tool) {\n return { ok: false, status: 'failed', terminal: false, retryable: false, message: `Unknown tool ${name}.` };\n }\n return tool.call(run, args);\n}\n\n/** OpenAI tool shape. `chat` for Chat Completions, `responses` for the Responses API. */\nexport function toOpenAITool(tool: ToolDefinition, api: 'chat' | 'responses' = 'chat'): Record<string, unknown> {\n if (api === 'responses') {\n return { type: 'function', name: tool.name, description: tool.description, parameters: tool.parameters };\n }\n return { type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } };\n}\n\n/** Anthropic Messages API tool shape. */\nexport function toAnthropicTool(tool: ToolDefinition): Record<string, unknown> {\n return { name: tool.name, description: tool.description, input_schema: tool.parameters };\n}\n\nfunction errorText(err: unknown): string {\n return (err instanceof Error ? err.message : String(err)).slice(0, 300);\n}\n","import type { Effect, OnceOptions, ReplyOnce } from '../replyonce.js';\nimport type { OnceResult } from '../types.js';\n\nexport interface WebhookOnceInput extends OnceOptions {\n /** Usually the conversation, ticket or channel the event belongs to. */\n scope: string;\n /** The provider's stable id for the event (for example Slack's `event_id`). */\n eventId: string;\n /** Ledger intent. Default `webhook`. */\n intent?: string;\n}\n\nexport interface WebhookOnceResponse<T> {\n result: OnceResult<T>;\n /** Suggested HTTP status for the provider. See `httpStatusFor`. */\n status: number;\n /** Seconds, set when the provider should retry later. */\n retryAfter?: number;\n}\n\n/**\n * Suggested response so at-least-once delivery keeps working without duplicating effects:\n *\n * - executed / replayed: 200, the event is handled.\n * - in_flight: 503 + Retry-After. Another delivery is still processing it; if that one\n * crashes, the provider's retry picks it up.\n * - failed and retryable: 500, let the provider retry.\n * - ambiguous, exhausted, or refused: 200. Redelivery cannot fix these; alert and reconcile.\n */\nexport function httpStatusFor(result: OnceResult<unknown>): { status: number; retryAfter?: number } {\n switch (result.status) {\n case 'executed':\n case 'replayed':\n return { status: 200 };\n case 'in_flight':\n return { status: 503, retryAfter: 5 };\n case 'failed':\n return result.retryable ? { status: 500 } : { status: 200 };\n default:\n return { status: 200 };\n }\n}\n\n/** Handle one webhook delivery at most once per (scope, eventId), however often it is redelivered. */\nexport async function webhookOnce<T>(\n ro: ReplyOnce,\n input: WebhookOnceInput,\n handler: Effect<T>,\n): Promise<WebhookOnceResponse<T>> {\n const { scope, eventId, intent = 'webhook', ...options } = input;\n const result = await ro.once({ scope, intent, payload: { eventId } }, handler, options);\n return { result, ...httpStatusFor(result) };\n}\n","import type { ReplyOnce, Run } from '../replyonce.js';\nimport type { RunMode } from '../types.js';\n\n/** The parts of a BullMQ `Job` replyonce reads. */\nexport interface JobLike<D = unknown> {\n id?: string;\n name?: string;\n queueName?: string;\n data: D;\n attemptsMade: number;\n opts?: { attempts?: number };\n}\n\nexport interface OnceProcessorOptions<D> {\n /** Scope of the job, usually the conversation id. */\n scope: (job: JobLike<D>) => string;\n /**\n * Stable run id. Default: `<queueName>:<job id>`. BullMQ keeps the job id across retries\n * and stalled redelivery, but ids are only unique within one queue.\n */\n runId?: (job: JobLike<D>) => string;\n mode?: RunMode;\n /**\n * A redelivered job whose run already claimed a write. `resume` (default) runs the\n * processor again; every `run.once` it already completed is replayed, not repeated.\n * `skip` never re-runs it, for processors whose effect identities are not deterministic.\n */\n onRedeliveredAfterWrite?: 'resume' | 'skip';\n onSkip?: (job: JobLike<D>, reason: SkipReason) => void;\n}\n\nexport type SkipReason = 'ended' | 'superseded' | 'redelivered_after_write';\n\nexport interface Skipped {\n replyonce: 'skipped';\n reason: SkipReason;\n}\n\n/** Thrown in `exclusive` mode while another live worker holds the scope, so the queue retries later. */\nexport class ScopeBusyError extends Error {\n readonly holderRunId: string;\n constructor(scope: string, holderRunId: string) {\n super(`replyonce: scope ${JSON.stringify(scope)} is held by run ${holderRunId}`);\n this.name = 'ScopeBusyError';\n this.holderRunId = holderRunId;\n }\n}\n\n/**\n * Wrap a BullMQ processor. Each job becomes a replyonce run keyed by job id, so retries\n * and stalled-job redelivery resume the same run and its completed effects are not redone.\n */\nexport function onceProcessor<D, R>(\n ro: ReplyOnce,\n options: OnceProcessorOptions<D>,\n processor: (job: JobLike<D>, run: Run) => Promise<R>,\n): (job: JobLike<D>) => Promise<R | Skipped> {\n return async (job) => {\n const scope = options.scope(job);\n if (!options.runId && !job.id) throw new TypeError('replyonce: job has no id; pass options.runId');\n const runId = options.runId ? options.runId(job) : job.queueName ? `${job.queueName}:${job.id}` : job.id!;\n const run = await ro.begin(scope, { runId, ...(options.mode ? { mode: options.mode } : {}) });\n\n const skip = (reason: SkipReason): Skipped => {\n options.onSkip?.(job, reason);\n return { replyonce: 'skipped', reason };\n };\n if (run.state === 'busy') throw new ScopeBusyError(scope, run.busyHolder!.runId);\n if (run.state === 'ended') return skip('ended');\n if (run.state === 'superseded') {\n await run.end('cancelled');\n return skip('superseded');\n }\n if (run.state === 'resumed' && run.alreadyWrote && options.onRedeliveredAfterWrite === 'skip') {\n await run.end('failed');\n return skip('redelivered_after_write');\n }\n\n try {\n const out = await processor(job, run);\n await run.end('completed');\n return out;\n } catch (err) {\n const attempts = job.opts?.attempts ?? 1;\n if (job.attemptsMade + 1 >= attempts) await run.end('failed').catch(() => undefined);\n throw err;\n }\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Run, ConflictMode, OnceOptions, ReplyOnce, Effect } from './index.cjs';
|
|
2
|
+
import { OnceResult, ConflictInfo, EffectContext, RunMode } from './index.cjs';
|
|
3
|
+
|
|
4
|
+
type JsonSchema = Record<string, unknown>;
|
|
5
|
+
interface ToolDefinition {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
parameters: JsonSchema;
|
|
9
|
+
}
|
|
10
|
+
interface GuardedToolSpec<A, R> extends ToolDefinition {
|
|
11
|
+
/** Ledger intent. Defaults to the tool name. */
|
|
12
|
+
intent?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The business identity of the action, from its arguments: what makes two calls "the
|
|
15
|
+
* same action". For a reply, the inbound message being answered; for a refund, the
|
|
16
|
+
* order and amount. Never the model's tool-call id.
|
|
17
|
+
*/
|
|
18
|
+
identity: (args: A) => unknown;
|
|
19
|
+
/** Short text other runs see if this write conflicts with theirs. */
|
|
20
|
+
summarize?: (args: A) => string;
|
|
21
|
+
conflict?: ConflictMode;
|
|
22
|
+
execute: (args: A, ctx: EffectContext) => Promise<R> | R;
|
|
23
|
+
}
|
|
24
|
+
/** What to hand back to the model. `terminal` means the run should stop. */
|
|
25
|
+
interface ToolOutcome {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
status: OnceResult<unknown>['status'];
|
|
28
|
+
terminal: boolean;
|
|
29
|
+
retryable: boolean;
|
|
30
|
+
message: string;
|
|
31
|
+
output?: unknown;
|
|
32
|
+
conflicts?: Array<Pick<ConflictInfo, 'intent' | 'status' | 'summary'>>;
|
|
33
|
+
}
|
|
34
|
+
interface GuardedTool<A = any, R = unknown> extends ToolDefinition {
|
|
35
|
+
intent: string;
|
|
36
|
+
identity: (args: A) => unknown;
|
|
37
|
+
call(run: Run, args: A): Promise<ToolOutcome>;
|
|
38
|
+
}
|
|
39
|
+
/** Wrap a side-effecting tool so a run executes it at most once per business identity. */
|
|
40
|
+
declare function guardTool<A, R>(spec: GuardedToolSpec<A, R>): GuardedTool<A, R>;
|
|
41
|
+
/** Turn a `once` result into a model-friendly tool result. */
|
|
42
|
+
declare function describeOutcome(result: OnceResult<unknown>): ToolOutcome;
|
|
43
|
+
/**
|
|
44
|
+
* A read-only "was this already done?" tool. Given a guarded tool name and, optionally,
|
|
45
|
+
* its arguments, it reports what the ledger knows. Add it alongside your write tools.
|
|
46
|
+
*/
|
|
47
|
+
declare function checkTool(tools: GuardedTool[], name?: string): GuardedTool<{
|
|
48
|
+
tool: string;
|
|
49
|
+
arguments?: unknown;
|
|
50
|
+
}, unknown>;
|
|
51
|
+
/** Route a model's tool call to the matching guarded tool. */
|
|
52
|
+
declare function dispatchTool(run: Run, tools: GuardedTool[], name: string, args: unknown): Promise<ToolOutcome>;
|
|
53
|
+
/** OpenAI tool shape. `chat` for Chat Completions, `responses` for the Responses API. */
|
|
54
|
+
declare function toOpenAITool(tool: ToolDefinition, api?: 'chat' | 'responses'): Record<string, unknown>;
|
|
55
|
+
/** Anthropic Messages API tool shape. */
|
|
56
|
+
declare function toAnthropicTool(tool: ToolDefinition): Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
interface WebhookOnceInput extends OnceOptions {
|
|
59
|
+
/** Usually the conversation, ticket or channel the event belongs to. */
|
|
60
|
+
scope: string;
|
|
61
|
+
/** The provider's stable id for the event (for example Slack's `event_id`). */
|
|
62
|
+
eventId: string;
|
|
63
|
+
/** Ledger intent. Default `webhook`. */
|
|
64
|
+
intent?: string;
|
|
65
|
+
}
|
|
66
|
+
interface WebhookOnceResponse<T> {
|
|
67
|
+
result: OnceResult<T>;
|
|
68
|
+
/** Suggested HTTP status for the provider. See `httpStatusFor`. */
|
|
69
|
+
status: number;
|
|
70
|
+
/** Seconds, set when the provider should retry later. */
|
|
71
|
+
retryAfter?: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Suggested response so at-least-once delivery keeps working without duplicating effects:
|
|
75
|
+
*
|
|
76
|
+
* - executed / replayed: 200, the event is handled.
|
|
77
|
+
* - in_flight: 503 + Retry-After. Another delivery is still processing it; if that one
|
|
78
|
+
* crashes, the provider's retry picks it up.
|
|
79
|
+
* - failed and retryable: 500, let the provider retry.
|
|
80
|
+
* - ambiguous, exhausted, or refused: 200. Redelivery cannot fix these; alert and reconcile.
|
|
81
|
+
*/
|
|
82
|
+
declare function httpStatusFor(result: OnceResult<unknown>): {
|
|
83
|
+
status: number;
|
|
84
|
+
retryAfter?: number;
|
|
85
|
+
};
|
|
86
|
+
/** Handle one webhook delivery at most once per (scope, eventId), however often it is redelivered. */
|
|
87
|
+
declare function webhookOnce<T>(ro: ReplyOnce, input: WebhookOnceInput, handler: Effect<T>): Promise<WebhookOnceResponse<T>>;
|
|
88
|
+
|
|
89
|
+
/** The parts of a BullMQ `Job` replyonce reads. */
|
|
90
|
+
interface JobLike<D = unknown> {
|
|
91
|
+
id?: string;
|
|
92
|
+
name?: string;
|
|
93
|
+
queueName?: string;
|
|
94
|
+
data: D;
|
|
95
|
+
attemptsMade: number;
|
|
96
|
+
opts?: {
|
|
97
|
+
attempts?: number;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
interface OnceProcessorOptions<D> {
|
|
101
|
+
/** Scope of the job, usually the conversation id. */
|
|
102
|
+
scope: (job: JobLike<D>) => string;
|
|
103
|
+
/**
|
|
104
|
+
* Stable run id. Default: `<queueName>:<job id>`. BullMQ keeps the job id across retries
|
|
105
|
+
* and stalled redelivery, but ids are only unique within one queue.
|
|
106
|
+
*/
|
|
107
|
+
runId?: (job: JobLike<D>) => string;
|
|
108
|
+
mode?: RunMode;
|
|
109
|
+
/**
|
|
110
|
+
* A redelivered job whose run already claimed a write. `resume` (default) runs the
|
|
111
|
+
* processor again; every `run.once` it already completed is replayed, not repeated.
|
|
112
|
+
* `skip` never re-runs it, for processors whose effect identities are not deterministic.
|
|
113
|
+
*/
|
|
114
|
+
onRedeliveredAfterWrite?: 'resume' | 'skip';
|
|
115
|
+
onSkip?: (job: JobLike<D>, reason: SkipReason) => void;
|
|
116
|
+
}
|
|
117
|
+
type SkipReason = 'ended' | 'superseded' | 'redelivered_after_write';
|
|
118
|
+
interface Skipped {
|
|
119
|
+
replyonce: 'skipped';
|
|
120
|
+
reason: SkipReason;
|
|
121
|
+
}
|
|
122
|
+
/** Thrown in `exclusive` mode while another live worker holds the scope, so the queue retries later. */
|
|
123
|
+
declare class ScopeBusyError extends Error {
|
|
124
|
+
readonly holderRunId: string;
|
|
125
|
+
constructor(scope: string, holderRunId: string);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Wrap a BullMQ processor. Each job becomes a replyonce run keyed by job id, so retries
|
|
129
|
+
* and stalled-job redelivery resume the same run and its completed effects are not redone.
|
|
130
|
+
*/
|
|
131
|
+
declare function onceProcessor<D, R>(ro: ReplyOnce, options: OnceProcessorOptions<D>, processor: (job: JobLike<D>, run: Run) => Promise<R>): (job: JobLike<D>) => Promise<R | Skipped>;
|
|
132
|
+
|
|
133
|
+
export { ScopeBusyError, checkTool, describeOutcome, dispatchTool, guardTool, httpStatusFor, onceProcessor, toAnthropicTool, toOpenAITool, webhookOnce };
|
|
134
|
+
export type { GuardedTool, GuardedToolSpec, JobLike, JsonSchema, OnceProcessorOptions, SkipReason, Skipped, ToolDefinition, ToolOutcome, WebhookOnceInput, WebhookOnceResponse };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Run, ConflictMode, OnceOptions, ReplyOnce, Effect } from './index.js';
|
|
2
|
+
import { OnceResult, ConflictInfo, EffectContext, RunMode } from './index.js';
|
|
3
|
+
|
|
4
|
+
type JsonSchema = Record<string, unknown>;
|
|
5
|
+
interface ToolDefinition {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
parameters: JsonSchema;
|
|
9
|
+
}
|
|
10
|
+
interface GuardedToolSpec<A, R> extends ToolDefinition {
|
|
11
|
+
/** Ledger intent. Defaults to the tool name. */
|
|
12
|
+
intent?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The business identity of the action, from its arguments: what makes two calls "the
|
|
15
|
+
* same action". For a reply, the inbound message being answered; for a refund, the
|
|
16
|
+
* order and amount. Never the model's tool-call id.
|
|
17
|
+
*/
|
|
18
|
+
identity: (args: A) => unknown;
|
|
19
|
+
/** Short text other runs see if this write conflicts with theirs. */
|
|
20
|
+
summarize?: (args: A) => string;
|
|
21
|
+
conflict?: ConflictMode;
|
|
22
|
+
execute: (args: A, ctx: EffectContext) => Promise<R> | R;
|
|
23
|
+
}
|
|
24
|
+
/** What to hand back to the model. `terminal` means the run should stop. */
|
|
25
|
+
interface ToolOutcome {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
status: OnceResult<unknown>['status'];
|
|
28
|
+
terminal: boolean;
|
|
29
|
+
retryable: boolean;
|
|
30
|
+
message: string;
|
|
31
|
+
output?: unknown;
|
|
32
|
+
conflicts?: Array<Pick<ConflictInfo, 'intent' | 'status' | 'summary'>>;
|
|
33
|
+
}
|
|
34
|
+
interface GuardedTool<A = any, R = unknown> extends ToolDefinition {
|
|
35
|
+
intent: string;
|
|
36
|
+
identity: (args: A) => unknown;
|
|
37
|
+
call(run: Run, args: A): Promise<ToolOutcome>;
|
|
38
|
+
}
|
|
39
|
+
/** Wrap a side-effecting tool so a run executes it at most once per business identity. */
|
|
40
|
+
declare function guardTool<A, R>(spec: GuardedToolSpec<A, R>): GuardedTool<A, R>;
|
|
41
|
+
/** Turn a `once` result into a model-friendly tool result. */
|
|
42
|
+
declare function describeOutcome(result: OnceResult<unknown>): ToolOutcome;
|
|
43
|
+
/**
|
|
44
|
+
* A read-only "was this already done?" tool. Given a guarded tool name and, optionally,
|
|
45
|
+
* its arguments, it reports what the ledger knows. Add it alongside your write tools.
|
|
46
|
+
*/
|
|
47
|
+
declare function checkTool(tools: GuardedTool[], name?: string): GuardedTool<{
|
|
48
|
+
tool: string;
|
|
49
|
+
arguments?: unknown;
|
|
50
|
+
}, unknown>;
|
|
51
|
+
/** Route a model's tool call to the matching guarded tool. */
|
|
52
|
+
declare function dispatchTool(run: Run, tools: GuardedTool[], name: string, args: unknown): Promise<ToolOutcome>;
|
|
53
|
+
/** OpenAI tool shape. `chat` for Chat Completions, `responses` for the Responses API. */
|
|
54
|
+
declare function toOpenAITool(tool: ToolDefinition, api?: 'chat' | 'responses'): Record<string, unknown>;
|
|
55
|
+
/** Anthropic Messages API tool shape. */
|
|
56
|
+
declare function toAnthropicTool(tool: ToolDefinition): Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
interface WebhookOnceInput extends OnceOptions {
|
|
59
|
+
/** Usually the conversation, ticket or channel the event belongs to. */
|
|
60
|
+
scope: string;
|
|
61
|
+
/** The provider's stable id for the event (for example Slack's `event_id`). */
|
|
62
|
+
eventId: string;
|
|
63
|
+
/** Ledger intent. Default `webhook`. */
|
|
64
|
+
intent?: string;
|
|
65
|
+
}
|
|
66
|
+
interface WebhookOnceResponse<T> {
|
|
67
|
+
result: OnceResult<T>;
|
|
68
|
+
/** Suggested HTTP status for the provider. See `httpStatusFor`. */
|
|
69
|
+
status: number;
|
|
70
|
+
/** Seconds, set when the provider should retry later. */
|
|
71
|
+
retryAfter?: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Suggested response so at-least-once delivery keeps working without duplicating effects:
|
|
75
|
+
*
|
|
76
|
+
* - executed / replayed: 200, the event is handled.
|
|
77
|
+
* - in_flight: 503 + Retry-After. Another delivery is still processing it; if that one
|
|
78
|
+
* crashes, the provider's retry picks it up.
|
|
79
|
+
* - failed and retryable: 500, let the provider retry.
|
|
80
|
+
* - ambiguous, exhausted, or refused: 200. Redelivery cannot fix these; alert and reconcile.
|
|
81
|
+
*/
|
|
82
|
+
declare function httpStatusFor(result: OnceResult<unknown>): {
|
|
83
|
+
status: number;
|
|
84
|
+
retryAfter?: number;
|
|
85
|
+
};
|
|
86
|
+
/** Handle one webhook delivery at most once per (scope, eventId), however often it is redelivered. */
|
|
87
|
+
declare function webhookOnce<T>(ro: ReplyOnce, input: WebhookOnceInput, handler: Effect<T>): Promise<WebhookOnceResponse<T>>;
|
|
88
|
+
|
|
89
|
+
/** The parts of a BullMQ `Job` replyonce reads. */
|
|
90
|
+
interface JobLike<D = unknown> {
|
|
91
|
+
id?: string;
|
|
92
|
+
name?: string;
|
|
93
|
+
queueName?: string;
|
|
94
|
+
data: D;
|
|
95
|
+
attemptsMade: number;
|
|
96
|
+
opts?: {
|
|
97
|
+
attempts?: number;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
interface OnceProcessorOptions<D> {
|
|
101
|
+
/** Scope of the job, usually the conversation id. */
|
|
102
|
+
scope: (job: JobLike<D>) => string;
|
|
103
|
+
/**
|
|
104
|
+
* Stable run id. Default: `<queueName>:<job id>`. BullMQ keeps the job id across retries
|
|
105
|
+
* and stalled redelivery, but ids are only unique within one queue.
|
|
106
|
+
*/
|
|
107
|
+
runId?: (job: JobLike<D>) => string;
|
|
108
|
+
mode?: RunMode;
|
|
109
|
+
/**
|
|
110
|
+
* A redelivered job whose run already claimed a write. `resume` (default) runs the
|
|
111
|
+
* processor again; every `run.once` it already completed is replayed, not repeated.
|
|
112
|
+
* `skip` never re-runs it, for processors whose effect identities are not deterministic.
|
|
113
|
+
*/
|
|
114
|
+
onRedeliveredAfterWrite?: 'resume' | 'skip';
|
|
115
|
+
onSkip?: (job: JobLike<D>, reason: SkipReason) => void;
|
|
116
|
+
}
|
|
117
|
+
type SkipReason = 'ended' | 'superseded' | 'redelivered_after_write';
|
|
118
|
+
interface Skipped {
|
|
119
|
+
replyonce: 'skipped';
|
|
120
|
+
reason: SkipReason;
|
|
121
|
+
}
|
|
122
|
+
/** Thrown in `exclusive` mode while another live worker holds the scope, so the queue retries later. */
|
|
123
|
+
declare class ScopeBusyError extends Error {
|
|
124
|
+
readonly holderRunId: string;
|
|
125
|
+
constructor(scope: string, holderRunId: string);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Wrap a BullMQ processor. Each job becomes a replyonce run keyed by job id, so retries
|
|
129
|
+
* and stalled-job redelivery resume the same run and its completed effects are not redone.
|
|
130
|
+
*/
|
|
131
|
+
declare function onceProcessor<D, R>(ro: ReplyOnce, options: OnceProcessorOptions<D>, processor: (job: JobLike<D>, run: Run) => Promise<R>): (job: JobLike<D>) => Promise<R | Skipped>;
|
|
132
|
+
|
|
133
|
+
export { ScopeBusyError, checkTool, describeOutcome, dispatchTool, guardTool, httpStatusFor, onceProcessor, toAnthropicTool, toOpenAITool, webhookOnce };
|
|
134
|
+
export type { GuardedTool, GuardedToolSpec, JobLike, JsonSchema, OnceProcessorOptions, SkipReason, Skipped, ToolDefinition, ToolOutcome, WebhookOnceInput, WebhookOnceResponse };
|