@telnyx/agent-harness 0.1.0-beta.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/README.md +179 -0
- package/dist/approvals.d.ts +140 -0
- package/dist/approvals.js +699 -0
- package/dist/channel.d.ts +178 -0
- package/dist/channel.js +184 -0
- package/dist/contract.d.ts +50 -0
- package/dist/contract.js +278 -0
- package/dist/durable.d.ts +81 -0
- package/dist/durable.js +650 -0
- package/dist/harness.d.ts +199 -0
- package/dist/harness.js +1223 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +19 -0
- package/dist/lifecycle.d.ts +9 -0
- package/dist/lifecycle.js +25 -0
- package/dist/node-adapter.d.ts +71 -0
- package/dist/node-adapter.js +736 -0
- package/dist/ports.d.ts +99 -0
- package/dist/ports.js +3 -0
- package/dist/runtime-adapter.d.ts +15 -0
- package/dist/runtime-adapter.js +70 -0
- package/dist/runtime-config.d.ts +38 -0
- package/dist/runtime-config.js +104 -0
- package/dist/scheduling.d.ts +46 -0
- package/dist/scheduling.js +425 -0
- package/dist/steps.d.ts +52 -0
- package/dist/steps.js +310 -0
- package/dist/tool-context.d.ts +8 -0
- package/dist/tool-context.js +9 -0
- package/dist/workspace.d.ts +151 -0
- package/dist/workspace.js +404 -0
- package/package.json +58 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { tool } from "ai";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import { HarnessAdmissionRejected, createScheduleAdmissionRollbackError, ensureHarnessDurabilitySchema } from "./durable.js";
|
|
5
|
+
import { createHarness } from "./harness.js";
|
|
6
|
+
import { currentHarnessToolContext } from "./tool-context.js";
|
|
7
|
+
const SCHEDULE_TASK_METHOD = "__telnyx_agent_harness_schedule";
|
|
8
|
+
const SCHEDULE_TASK_PREFIX = `${SCHEDULE_TASK_METHOD}/`;
|
|
9
|
+
const MAX_ID_BYTES = 512;
|
|
10
|
+
const MAX_INPUT_BYTES = 64 * 1024;
|
|
11
|
+
const MAX_ACTIVE_SCHEDULES = 32;
|
|
12
|
+
const SCHEDULE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
13
|
+
function first(rows) { return rows[0]; }
|
|
14
|
+
function requireBoundedNonblank(value, name, maximum) {
|
|
15
|
+
if (!value.trim())
|
|
16
|
+
throw new Error(`${name} must not be blank`);
|
|
17
|
+
if (Buffer.byteLength(value, "utf8") > maximum)
|
|
18
|
+
throw new Error(`${name} exceeds ${maximum} UTF-8 bytes`);
|
|
19
|
+
}
|
|
20
|
+
function requireScheduleId(value) {
|
|
21
|
+
requireBoundedNonblank(value, "schedule id", MAX_ID_BYTES);
|
|
22
|
+
if (!SCHEDULE_ID_PATTERN.test(value))
|
|
23
|
+
throw new Error("schedule id must be canonical");
|
|
24
|
+
}
|
|
25
|
+
function isScheduleId(value) {
|
|
26
|
+
try {
|
|
27
|
+
requireScheduleId(value);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function requireSeconds(mode, seconds) {
|
|
35
|
+
if (!Number.isFinite(seconds) || seconds < 0 || (mode === "interval" && seconds === 0)) {
|
|
36
|
+
throw new Error(mode === "delay" ? "delay seconds must be finite and non-negative" : "interval seconds must be finite and positive");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function taskId(id) { return `${SCHEDULE_TASK_PREFIX}${id}`; }
|
|
40
|
+
function keyForOccurrence(id, scheduledFor) {
|
|
41
|
+
return `schedule:${id}:${scheduledFor}`;
|
|
42
|
+
}
|
|
43
|
+
function schedulePayload(id, scheduledFor) {
|
|
44
|
+
return Object.freeze({ id, scheduledFor });
|
|
45
|
+
}
|
|
46
|
+
function dueDelaySeconds(now, due) {
|
|
47
|
+
return Math.max(0, (due - now) / 1_000);
|
|
48
|
+
}
|
|
49
|
+
function publicSchedule(row) {
|
|
50
|
+
if (row.mode === "delay") {
|
|
51
|
+
return Object.freeze({ id: row.id, mode: "delay", nextFireAt: row.next_due_at, status: "active" });
|
|
52
|
+
}
|
|
53
|
+
return Object.freeze({ id: row.id, mode: "interval", intervalSeconds: row.seconds, nextFireAt: row.next_due_at, status: "active" });
|
|
54
|
+
}
|
|
55
|
+
function scheduleMode(request) {
|
|
56
|
+
const hasDelay = request.delaySeconds !== undefined;
|
|
57
|
+
const hasInterval = request.intervalSeconds !== undefined;
|
|
58
|
+
if (hasDelay === hasInterval)
|
|
59
|
+
throw new Error("schedule requires exactly one delaySeconds or intervalSeconds");
|
|
60
|
+
const mode = hasDelay ? "delay" : "interval";
|
|
61
|
+
const seconds = hasDelay ? request.delaySeconds : request.intervalSeconds;
|
|
62
|
+
requireSeconds(mode, seconds);
|
|
63
|
+
return Object.freeze({ mode, seconds });
|
|
64
|
+
}
|
|
65
|
+
function taskScheduleIdentity(task) {
|
|
66
|
+
if (task.name !== SCHEDULE_TASK_METHOD || task.payload === null || typeof task.payload !== "object" || Array.isArray(task.payload))
|
|
67
|
+
return undefined;
|
|
68
|
+
const id = Object.getOwnPropertyDescriptor(task.payload, "id");
|
|
69
|
+
const scheduledFor = Object.getOwnPropertyDescriptor(task.payload, "scheduledFor");
|
|
70
|
+
if (id === undefined || !("value" in id) || typeof id.value !== "string"
|
|
71
|
+
|| scheduledFor === undefined || !("value" in scheduledFor) || !Number.isSafeInteger(scheduledFor.value) || scheduledFor.value < 0)
|
|
72
|
+
return undefined;
|
|
73
|
+
try {
|
|
74
|
+
requireScheduleId(id.value);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return task.id === taskId(id.value) ? Object.freeze({ id: id.value, scheduledFor: scheduledFor.value }) : undefined;
|
|
80
|
+
}
|
|
81
|
+
function ensureSchedulingSchema(ports) {
|
|
82
|
+
ports.sql.transactionSync(() => {
|
|
83
|
+
ports.sql.exec("CREATE TABLE IF NOT EXISTS __telnyx_agent_harness_schedules (id TEXT PRIMARY KEY NOT NULL, mode TEXT NOT NULL CHECK(mode IN ('delay','interval')), seconds REAL NOT NULL, input TEXT NOT NULL, next_due_at INTEGER NOT NULL, active INTEGER NOT NULL CHECK(active IN (0,1)), origin_run_id TEXT, origin_tool_ordinal INTEGER, origin_journal_marker TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)");
|
|
84
|
+
const columns = new Set((ports.sql.exec("PRAGMA table_info('__telnyx_agent_harness_schedules')").toArray()).map((column) => column.name));
|
|
85
|
+
if (!columns.has("origin_run_id"))
|
|
86
|
+
ports.sql.exec("ALTER TABLE __telnyx_agent_harness_schedules ADD COLUMN origin_run_id TEXT");
|
|
87
|
+
if (!columns.has("origin_tool_ordinal"))
|
|
88
|
+
ports.sql.exec("ALTER TABLE __telnyx_agent_harness_schedules ADD COLUMN origin_tool_ordinal INTEGER");
|
|
89
|
+
if (!columns.has("origin_journal_marker"))
|
|
90
|
+
ports.sql.exec("ALTER TABLE __telnyx_agent_harness_schedules ADD COLUMN origin_journal_marker TEXT");
|
|
91
|
+
ports.sql.exec("CREATE TABLE IF NOT EXISTS __telnyx_agent_harness_schedule_occurrences (schedule_id TEXT NOT NULL, scheduled_for INTEGER NOT NULL, run_id TEXT, journal_seq INTEGER, status TEXT NOT NULL CHECK(status IN ('pending','admitted','canceled')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(schedule_id,scheduled_for), FOREIGN KEY(schedule_id) REFERENCES __telnyx_agent_harness_schedules(id))");
|
|
92
|
+
const occurrenceColumns = new Set((ports.sql.exec("PRAGMA table_info('__telnyx_agent_harness_schedule_occurrences')").toArray()).map((column) => column.name));
|
|
93
|
+
if (!occurrenceColumns.has("journal_seq"))
|
|
94
|
+
ports.sql.exec("ALTER TABLE __telnyx_agent_harness_schedule_occurrences ADD COLUMN journal_seq INTEGER");
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Creates the bounded v0 model-facing schedule tools and the matching proactive
|
|
99
|
+
* task handler. Schedule definitions and occurrence-to-run correlation are
|
|
100
|
+
* private durable state; only active harness-owned schedules are projected.
|
|
101
|
+
*/
|
|
102
|
+
export function createHarnessScheduling(ports, ledger) {
|
|
103
|
+
let initialized = false;
|
|
104
|
+
const ensure = () => {
|
|
105
|
+
if (initialized)
|
|
106
|
+
return;
|
|
107
|
+
ensureHarnessDurabilitySchema(ports);
|
|
108
|
+
ensureSchedulingSchema(ports);
|
|
109
|
+
initialized = true;
|
|
110
|
+
};
|
|
111
|
+
const readSchedule = (id) => first(ports.sql.exec("SELECT id,mode,seconds,input,next_due_at,active,origin_run_id,origin_tool_ordinal,origin_journal_marker FROM __telnyx_agent_harness_schedules WHERE id = ?", id).toArray());
|
|
112
|
+
const schedule = async (request, context) => {
|
|
113
|
+
ensure();
|
|
114
|
+
const { mode, seconds } = scheduleMode(request);
|
|
115
|
+
requireBoundedNonblank(request.prompt, "schedule prompt", MAX_INPUT_BYTES);
|
|
116
|
+
const origin = context === undefined ? undefined : (() => {
|
|
117
|
+
if (context.toolName !== "schedule")
|
|
118
|
+
throw new Error("schedule tool context is invalid");
|
|
119
|
+
const row = first(ports.sql.exec("SELECT journal_marker FROM __telnyx_agent_harness_tool_calls WHERE run_id = ? AND tool_name = ? AND ordinal = ?", context.runId, context.toolName, context.ordinal).toArray());
|
|
120
|
+
if (row?.journal_marker === null || row?.journal_marker === undefined)
|
|
121
|
+
throw new Error("schedule tool journal linkage is unavailable");
|
|
122
|
+
return Object.freeze({ ...context, journalMarker: row.journal_marker });
|
|
123
|
+
})();
|
|
124
|
+
const id = origin === undefined
|
|
125
|
+
? `schedule-${crypto.randomUUID()}`
|
|
126
|
+
: `schedule-${createHash("sha256").update(`${origin.runId}\u0000${origin.toolName}\u0000${origin.ordinal}`).digest("hex")}`;
|
|
127
|
+
requireScheduleId(id);
|
|
128
|
+
const now = ports.clock.now();
|
|
129
|
+
const nextDueAt = Math.floor(now + seconds * 1_000);
|
|
130
|
+
if (!Number.isSafeInteger(nextDueAt) || nextDueAt < 0)
|
|
131
|
+
throw new Error("schedule deadline is invalid");
|
|
132
|
+
const admitted = ports.sql.transactionSync(() => {
|
|
133
|
+
// Durable tool replay uses the same deterministic id. An already committed
|
|
134
|
+
// schedule is authoritative even when the actor is at capacity; do not
|
|
135
|
+
// overwrite or re-arm it if a later replay cannot reach the host scheduler.
|
|
136
|
+
const existing = readSchedule(id);
|
|
137
|
+
if (existing?.active === 1)
|
|
138
|
+
return Object.freeze({ created: false, schedule: existing });
|
|
139
|
+
const count = first(ports.sql.exec("SELECT COUNT(*) AS count FROM __telnyx_agent_harness_schedules WHERE active = 1").toArray())?.count ?? 0;
|
|
140
|
+
if (count >= MAX_ACTIVE_SCHEDULES)
|
|
141
|
+
throw new Error(`active schedule limit (${MAX_ACTIVE_SCHEDULES}) reached`);
|
|
142
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_schedules(id,mode,seconds,input,next_due_at,active,origin_run_id,origin_tool_ordinal,origin_journal_marker,created_at,updated_at) VALUES (?,?,?,?,?,1,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET mode = excluded.mode, seconds = excluded.seconds, input = excluded.input, next_due_at = excluded.next_due_at, active = 1, origin_run_id = excluded.origin_run_id, origin_tool_ordinal = excluded.origin_tool_ordinal, origin_journal_marker = excluded.origin_journal_marker, updated_at = excluded.updated_at", id, mode, seconds, request.prompt, nextDueAt, origin?.runId ?? null, origin?.ordinal ?? null, origin?.journalMarker ?? null, now, now);
|
|
143
|
+
return Object.freeze({ created: true, schedule: Object.freeze({ id, mode, next_due_at: nextDueAt }) });
|
|
144
|
+
});
|
|
145
|
+
if (!admitted.created) {
|
|
146
|
+
const { id: existingId, mode: existingMode, next_due_at: existingNextFireAt } = admitted.schedule;
|
|
147
|
+
return Object.freeze({ id: existingId, mode: existingMode, nextFireAt: existingNextFireAt });
|
|
148
|
+
}
|
|
149
|
+
// Stable namespaced task ids make an upsert/re-arm independent of unrelated Agent tasks.
|
|
150
|
+
try {
|
|
151
|
+
if (mode === "delay") {
|
|
152
|
+
await ports.tasks.schedule(seconds, SCHEDULE_TASK_METHOD, schedulePayload(id, nextDueAt), { id: taskId(id), maxRetries: 5 });
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
await ports.tasks.schedule(seconds, SCHEDULE_TASK_METHOD, schedulePayload(id, nextDueAt), { id: taskId(id), maxRetries: 5 });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
ports.sql.transactionSync(() => ports.sql.exec("DELETE FROM __telnyx_agent_harness_schedules WHERE id = ? AND active = 1", id));
|
|
160
|
+
if (context !== undefined)
|
|
161
|
+
throw createScheduleAdmissionRollbackError(`tool:${context.toolName}:${context.ordinal}`, error);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
return Object.freeze({ id, mode, nextFireAt: nextDueAt });
|
|
165
|
+
};
|
|
166
|
+
const listSchedules = async () => {
|
|
167
|
+
ensure();
|
|
168
|
+
const rows = ports.sql.exec("SELECT id,mode,seconds,input,next_due_at,active FROM __telnyx_agent_harness_schedules WHERE active = 1 ORDER BY id").toArray();
|
|
169
|
+
return Object.freeze(rows.map(publicSchedule));
|
|
170
|
+
};
|
|
171
|
+
const cancelSchedule = async (id) => {
|
|
172
|
+
ensure();
|
|
173
|
+
requireScheduleId(id);
|
|
174
|
+
const canceled = ports.sql.transactionSync(() => {
|
|
175
|
+
const current = readSchedule(id);
|
|
176
|
+
if (current === undefined || current.active !== 1)
|
|
177
|
+
return false;
|
|
178
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_schedules SET active = 0, updated_at = ? WHERE id = ? AND active = 1", ports.clock.now(), id);
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
181
|
+
if (canceled) {
|
|
182
|
+
try {
|
|
183
|
+
await ports.tasks.cancel(taskId(id));
|
|
184
|
+
}
|
|
185
|
+
catch { /* durable inactive state fences late delivery */ }
|
|
186
|
+
}
|
|
187
|
+
return Object.freeze({ id, canceled });
|
|
188
|
+
};
|
|
189
|
+
const admitOccurrence = async (_task, identity) => {
|
|
190
|
+
ensure();
|
|
191
|
+
const { id, scheduledFor } = identity;
|
|
192
|
+
const prepared = ports.sql.transactionSync(() => {
|
|
193
|
+
const current = readSchedule(id);
|
|
194
|
+
if (current === undefined || current.active !== 1)
|
|
195
|
+
return undefined;
|
|
196
|
+
const occurrence = first(ports.sql.exec("SELECT schedule_id,scheduled_for,run_id,journal_seq FROM __telnyx_agent_harness_schedule_occurrences WHERE schedule_id = ? AND scheduled_for = ?", id, scheduledFor).toArray());
|
|
197
|
+
if (occurrence?.run_id !== null && occurrence?.run_id !== undefined)
|
|
198
|
+
return Object.freeze({ schedule: current, occurrence });
|
|
199
|
+
if (occurrence === undefined) {
|
|
200
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_schedule_occurrences(schedule_id,scheduled_for,run_id,status,created_at,updated_at) VALUES (?,?,NULL,'pending',?,?)", id, scheduledFor, ports.clock.now(), ports.clock.now());
|
|
201
|
+
}
|
|
202
|
+
return Object.freeze({ schedule: current, occurrence: undefined });
|
|
203
|
+
});
|
|
204
|
+
if (prepared === undefined)
|
|
205
|
+
return;
|
|
206
|
+
if (prepared.occurrence?.run_id !== undefined && prepared.occurrence.run_id !== null) {
|
|
207
|
+
if (prepared.schedule.active === 1 && prepared.schedule.mode === "interval") {
|
|
208
|
+
const delay = dueDelaySeconds(ports.clock.now(), prepared.schedule.next_due_at);
|
|
209
|
+
await ports.tasks.schedule(delay, SCHEDULE_TASK_METHOD, schedulePayload(id, prepared.schedule.next_due_at), { id: taskId(id), maxRetries: 5 });
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// Cancellation is authoritative until admission begins; an already admitted
|
|
214
|
+
// run is intentionally left to the existing run-cancellation surface.
|
|
215
|
+
const stillActive = ports.sql.transactionSync(() => readSchedule(id)?.active === 1);
|
|
216
|
+
if (!stillActive) {
|
|
217
|
+
ports.sql.transactionSync(() => ports.sql.exec("UPDATE __telnyx_agent_harness_schedule_occurrences SET status = 'canceled', updated_at = ? WHERE schedule_id = ? AND scheduled_for = ? AND run_id IS NULL", ports.clock.now(), id, scheduledFor));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
let accepted;
|
|
221
|
+
try {
|
|
222
|
+
const journalSeq = await ports.messages.count();
|
|
223
|
+
accepted = await ledger.accept(prepared.schedule.input, {
|
|
224
|
+
key: keyForOccurrence(id, scheduledFor),
|
|
225
|
+
admit: () => readSchedule(id)?.active === 1,
|
|
226
|
+
journalSeq,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (!(error instanceof HarnessAdmissionRejected))
|
|
231
|
+
throw error;
|
|
232
|
+
ports.sql.transactionSync(() => ports.sql.exec("UPDATE __telnyx_agent_harness_schedule_occurrences SET status = 'canceled', updated_at = ? WHERE schedule_id = ? AND scheduled_for = ? AND run_id IS NULL", ports.clock.now(), id, scheduledFor));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const nextInterval = ports.sql.transactionSync(() => {
|
|
236
|
+
const current = readSchedule(id);
|
|
237
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_schedule_occurrences SET run_id = ?, status = 'admitted', updated_at = ? WHERE schedule_id = ? AND scheduled_for = ? AND run_id IS NULL", accepted.runId, ports.clock.now(), id, scheduledFor);
|
|
238
|
+
if (current?.active === 1 && current.mode === "delay") {
|
|
239
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_schedules SET active = 0, updated_at = ? WHERE id = ?", ports.clock.now(), id);
|
|
240
|
+
}
|
|
241
|
+
else if (current?.active === 1 && current.mode === "interval") {
|
|
242
|
+
const nextFireAt = Math.floor(ports.clock.now() + current.seconds * 1_000);
|
|
243
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_schedules SET next_due_at = ?, updated_at = ? WHERE id = ?", nextFireAt, ports.clock.now(), id);
|
|
244
|
+
return Object.freeze({ id: current.id, nextFireAt });
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
});
|
|
248
|
+
if (nextInterval !== undefined) {
|
|
249
|
+
await ports.tasks.schedule(dueDelaySeconds(ports.clock.now(), nextInterval.nextFireAt), SCHEDULE_TASK_METHOD, schedulePayload(nextInterval.id, nextInterval.nextFireAt), { id: taskId(nextInterval.id), maxRetries: 5 });
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const dispatch = async (task) => {
|
|
253
|
+
ensure();
|
|
254
|
+
const identity = taskScheduleIdentity(task);
|
|
255
|
+
if (task.name === SCHEDULE_TASK_METHOD) {
|
|
256
|
+
if (typeof task.id !== "string" || !task.id)
|
|
257
|
+
throw new Error("scheduled task is invalid");
|
|
258
|
+
if (identity === undefined) {
|
|
259
|
+
if (task.id.startsWith(SCHEDULE_TASK_PREFIX))
|
|
260
|
+
throw new Error("scheduled task is invalid");
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
await admitOccurrence(task, identity);
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
const dispatched = await ledger.dispatch(task);
|
|
267
|
+
if (dispatched !== null && typeof dispatched === "object"
|
|
268
|
+
&& "id" in dispatched && typeof dispatched.id === "string"
|
|
269
|
+
&& "status" in dispatched && dispatched.status === "completed") {
|
|
270
|
+
// This runs directly after the durable run's journal barrier, while the
|
|
271
|
+
// actor turn is serialized. Persisting that exact sequence makes the
|
|
272
|
+
// occurrence -> run -> journal relationship recoverable without trying
|
|
273
|
+
// to infer it from actor-global history later.
|
|
274
|
+
// The durable run is already terminal. Correlation is repaired from the
|
|
275
|
+
// run-owned journal marker during recovery, so a transient read failure
|
|
276
|
+
// must not make the host retry this completed run task.
|
|
277
|
+
ports.sql.transactionSync(() => ports.sql.exec("UPDATE __telnyx_agent_harness_schedule_occurrences SET journal_seq = (SELECT journal_seq FROM __telnyx_agent_harness_run_journal WHERE run_id = __telnyx_agent_harness_schedule_occurrences.run_id), updated_at = ? WHERE run_id = ? AND status = 'admitted' AND EXISTS (SELECT 1 FROM __telnyx_agent_harness_run_journal WHERE run_id = __telnyx_agent_harness_schedule_occurrences.run_id)", ports.clock.now(), dispatched.id));
|
|
278
|
+
}
|
|
279
|
+
return dispatched;
|
|
280
|
+
};
|
|
281
|
+
const recover = async () => {
|
|
282
|
+
ensure();
|
|
283
|
+
// A process can stop after ledger.accept() commits its idempotent run but
|
|
284
|
+
// before the occurrence receives that run id. Reconcile that committed
|
|
285
|
+
// admission before considering the schedule's current active state:
|
|
286
|
+
// cancellation after admission must not erase the admitted occurrence.
|
|
287
|
+
ports.sql.transactionSync(() => ports.sql.exec("UPDATE __telnyx_agent_harness_schedule_occurrences SET run_id = (SELECT id FROM __telnyx_agent_harness_runs WHERE key = ('schedule:' || __telnyx_agent_harness_schedule_occurrences.schedule_id || ':' || __telnyx_agent_harness_schedule_occurrences.scheduled_for)), status = 'admitted', updated_at = ? WHERE status = 'pending' AND run_id IS NULL AND EXISTS (SELECT 1 FROM __telnyx_agent_harness_runs WHERE key = ('schedule:' || __telnyx_agent_harness_schedule_occurrences.schedule_id || ':' || __telnyx_agent_harness_schedule_occurrences.scheduled_for))", ports.clock.now()));
|
|
288
|
+
// The run-owned marker is committed by the durable turn before its final
|
|
289
|
+
// run transition, so this repair never guesses from later actor history.
|
|
290
|
+
ports.sql.transactionSync(() => ports.sql.exec("UPDATE __telnyx_agent_harness_schedule_occurrences SET journal_seq = (SELECT journal_seq FROM __telnyx_agent_harness_run_journal WHERE run_id = __telnyx_agent_harness_schedule_occurrences.run_id), updated_at = ? WHERE status = 'admitted' AND run_id IS NOT NULL AND EXISTS (SELECT 1 FROM __telnyx_agent_harness_run_journal WHERE run_id = __telnyx_agent_harness_schedule_occurrences.run_id)", ports.clock.now()));
|
|
291
|
+
const pending = ports.sql.exec("SELECT schedules.id,schedules.mode,schedules.seconds,schedules.input,schedules.next_due_at,schedules.active,occurrences.scheduled_for FROM __telnyx_agent_harness_schedule_occurrences AS occurrences JOIN __telnyx_agent_harness_schedules AS schedules ON schedules.id = occurrences.schedule_id WHERE occurrences.status = 'pending' AND occurrences.run_id IS NULL AND schedules.active = 1 ORDER BY occurrences.scheduled_for,schedules.id").toArray();
|
|
292
|
+
let repaired = 0;
|
|
293
|
+
for (const occurrence of pending) {
|
|
294
|
+
let accepted;
|
|
295
|
+
try {
|
|
296
|
+
const journalSeq = await ports.messages.count();
|
|
297
|
+
accepted = await ledger.accept(occurrence.input, {
|
|
298
|
+
key: keyForOccurrence(occurrence.id, occurrence.scheduled_for),
|
|
299
|
+
admit: () => readSchedule(occurrence.id)?.active === 1,
|
|
300
|
+
journalSeq,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (!(error instanceof HarnessAdmissionRejected))
|
|
305
|
+
throw error;
|
|
306
|
+
ports.sql.transactionSync(() => ports.sql.exec("UPDATE __telnyx_agent_harness_schedule_occurrences SET status = 'canceled', updated_at = ? WHERE schedule_id = ? AND scheduled_for = ? AND run_id IS NULL", ports.clock.now(), occurrence.id, occurrence.scheduled_for));
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
ports.sql.transactionSync(() => {
|
|
310
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_schedule_occurrences SET run_id = ?, status = 'admitted', updated_at = ? WHERE schedule_id = ? AND scheduled_for = ? AND run_id IS NULL AND status = 'pending'", accepted.runId, ports.clock.now(), occurrence.id, occurrence.scheduled_for);
|
|
311
|
+
if (occurrence.mode === "delay") {
|
|
312
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_schedules SET active = 0, updated_at = ? WHERE id = ? AND active = 1", ports.clock.now(), occurrence.id);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_schedules SET next_due_at = ?, updated_at = ? WHERE id = ? AND active = 1", Math.floor(ports.clock.now() + occurrence.seconds * 1_000), ports.clock.now(), occurrence.id);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
repaired += 1;
|
|
319
|
+
}
|
|
320
|
+
const active = ports.sql.exec("SELECT id,mode,seconds,input,next_due_at,active FROM __telnyx_agent_harness_schedules WHERE active = 1 ORDER BY id").toArray();
|
|
321
|
+
const expected = new Set(active.map((row) => taskId(row.id)));
|
|
322
|
+
const tasks = await ports.tasks.list();
|
|
323
|
+
for (const task of tasks) {
|
|
324
|
+
// Prefix resemblance is not ownership. Only a fully validated private
|
|
325
|
+
// envelope can be reconciled or canceled by this harness.
|
|
326
|
+
if (taskScheduleIdentity(task) !== undefined && !expected.has(task.id)) {
|
|
327
|
+
try {
|
|
328
|
+
if (await ports.tasks.cancel(task.id))
|
|
329
|
+
repaired += 1;
|
|
330
|
+
}
|
|
331
|
+
catch { /* retry on next recovery */ }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const present = new Map(tasks.map((task) => [task.id, task]));
|
|
335
|
+
for (const row of active) {
|
|
336
|
+
const id = taskId(row.id);
|
|
337
|
+
const task = present.get(id);
|
|
338
|
+
const everyMs = undefined;
|
|
339
|
+
if (task !== undefined
|
|
340
|
+
&& task.name === SCHEDULE_TASK_METHOD
|
|
341
|
+
&& task.due === row.next_due_at
|
|
342
|
+
&& task.everyMs === everyMs
|
|
343
|
+
&& taskScheduleIdentity(task)?.id === row.id
|
|
344
|
+
&& taskScheduleIdentity(task)?.scheduledFor === row.next_due_at)
|
|
345
|
+
continue;
|
|
346
|
+
const delay = dueDelaySeconds(ports.clock.now(), row.next_due_at);
|
|
347
|
+
if (row.mode === "delay")
|
|
348
|
+
await ports.tasks.schedule(delay, SCHEDULE_TASK_METHOD, schedulePayload(row.id, row.next_due_at), { id, maxRetries: 5 });
|
|
349
|
+
else
|
|
350
|
+
await ports.tasks.schedule(delay, SCHEDULE_TASK_METHOD, schedulePayload(row.id, row.next_due_at), { id, maxRetries: 5 });
|
|
351
|
+
repaired += 1;
|
|
352
|
+
}
|
|
353
|
+
return repaired;
|
|
354
|
+
};
|
|
355
|
+
const tools = Object.freeze({
|
|
356
|
+
schedule: tool({
|
|
357
|
+
inputSchema: z.object({
|
|
358
|
+
prompt: z.string(),
|
|
359
|
+
delaySeconds: z.number().optional(),
|
|
360
|
+
intervalSeconds: z.number().optional(),
|
|
361
|
+
}).refine((input) => (input.delaySeconds === undefined) !== (input.intervalSeconds === undefined), {
|
|
362
|
+
message: "schedule requires exactly one delaySeconds or intervalSeconds",
|
|
363
|
+
}),
|
|
364
|
+
execute: (async (input) => {
|
|
365
|
+
const context = currentHarnessToolContext();
|
|
366
|
+
if (context === undefined)
|
|
367
|
+
throw new Error("schedule tool requires durable admission");
|
|
368
|
+
return await schedule(input, context);
|
|
369
|
+
}),
|
|
370
|
+
}),
|
|
371
|
+
list_schedules: tool({
|
|
372
|
+
inputSchema: z.object({}),
|
|
373
|
+
execute: (async (_input) => {
|
|
374
|
+
const context = currentHarnessToolContext();
|
|
375
|
+
if (context === undefined)
|
|
376
|
+
throw new Error("list_schedules tool requires durable admission");
|
|
377
|
+
return await listSchedules();
|
|
378
|
+
}),
|
|
379
|
+
}),
|
|
380
|
+
cancel_schedule: tool({
|
|
381
|
+
inputSchema: z.object({ id: z.string().refine(isScheduleId, { message: "schedule id must be bounded and canonical" }) }),
|
|
382
|
+
execute: (async ({ id }) => {
|
|
383
|
+
const context = currentHarnessToolContext();
|
|
384
|
+
if (context === undefined)
|
|
385
|
+
throw new Error("cancel_schedule tool requires durable admission");
|
|
386
|
+
return await cancelSchedule(id);
|
|
387
|
+
}),
|
|
388
|
+
}),
|
|
389
|
+
});
|
|
390
|
+
return Object.freeze({ tools, schedule, listSchedules, cancelSchedule, dispatch, recover });
|
|
391
|
+
}
|
|
392
|
+
/** Construct one durable harness with the bounded scheduling tools in its visible tool set. */
|
|
393
|
+
export function createHarnessWithScheduling(spec) {
|
|
394
|
+
let baseHarness;
|
|
395
|
+
const requireBaseHarness = () => {
|
|
396
|
+
if (baseHarness === undefined)
|
|
397
|
+
throw new Error("harness scheduling is unavailable during construction");
|
|
398
|
+
return baseHarness;
|
|
399
|
+
};
|
|
400
|
+
const ledger = Object.freeze({
|
|
401
|
+
accept: async (input, options) => requireBaseHarness().accept(input, options),
|
|
402
|
+
get: async (runId) => requireBaseHarness().get(runId),
|
|
403
|
+
status: async (runId) => requireBaseHarness().status(runId),
|
|
404
|
+
list: async (options) => requireBaseHarness().list(options),
|
|
405
|
+
cancel: async (runId) => requireBaseHarness().cancel(runId),
|
|
406
|
+
recover: async () => requireBaseHarness().recover(),
|
|
407
|
+
run: async (runId) => requireBaseHarness().run(runId),
|
|
408
|
+
dispatch: async (task) => requireBaseHarness().dispatch(task),
|
|
409
|
+
});
|
|
410
|
+
const scheduling = createHarnessScheduling(spec.ports, ledger);
|
|
411
|
+
const collisions = Object.keys(spec.tools).filter((name) => Object.hasOwn(scheduling.tools, name));
|
|
412
|
+
if (collisions.length > 0)
|
|
413
|
+
throw new TypeError(`Harness scheduling tools collide with supplied tools: ${collisions.join(",")}`);
|
|
414
|
+
const activeTools = spec.activeTools === undefined
|
|
415
|
+
? undefined
|
|
416
|
+
: Object.freeze([...new Set([...spec.activeTools, ...Object.keys(scheduling.tools)])]);
|
|
417
|
+
baseHarness = createHarness({ ...spec, tools: Object.freeze({ ...spec.tools, ...scheduling.tools }), ...(activeTools === undefined ? {} : { activeTools: activeTools }) });
|
|
418
|
+
const harness = Object.freeze({
|
|
419
|
+
...baseHarness,
|
|
420
|
+
dispatch: scheduling.dispatch,
|
|
421
|
+
recover: async () => (await baseHarness.recover()) + (await scheduling.recover()),
|
|
422
|
+
});
|
|
423
|
+
return Object.freeze({ harness, scheduling });
|
|
424
|
+
}
|
|
425
|
+
//# sourceMappingURL=scheduling.js.map
|
package/dist/steps.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AgentHarnessPorts } from "./ports.js";
|
|
2
|
+
import { type HarnessCheckpoint } from "./durable.js";
|
|
3
|
+
export interface HarnessStepContextOptions {
|
|
4
|
+
readonly runId: string;
|
|
5
|
+
readonly input: unknown;
|
|
6
|
+
readonly version: string;
|
|
7
|
+
/** True only when this durable run is replaying after interruption or approval. */
|
|
8
|
+
readonly isContinuation?: boolean;
|
|
9
|
+
/** Deterministic test seam. Production callers must not use this to infer external-effect success. */
|
|
10
|
+
readonly checkpoints?: HarnessCheckpoint;
|
|
11
|
+
}
|
|
12
|
+
export interface HarnessStepOptions {
|
|
13
|
+
/** Marks a call that can mutate an external system and therefore needs reconciliation after an interrupted effect. */
|
|
14
|
+
readonly external?: boolean;
|
|
15
|
+
/** JSON-safe effect input used for this step's durable memo fingerprint. */
|
|
16
|
+
readonly input?: unknown;
|
|
17
|
+
/** Explicit effect contract version used for this step's durable memo identity. */
|
|
18
|
+
readonly version?: string;
|
|
19
|
+
/** Private synchronous hook committed with the external effect-start fence. */
|
|
20
|
+
readonly beforeEffect?: () => void;
|
|
21
|
+
}
|
|
22
|
+
export interface HarnessStepContext {
|
|
23
|
+
/** Private durable run identity for harness-owned continuation state. */
|
|
24
|
+
readonly runId: string;
|
|
25
|
+
/** True only when this durable run is replaying after interruption or approval. */
|
|
26
|
+
readonly isContinuation: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Executes a named effect once per stable run/input/version identity, then
|
|
29
|
+
* durably returns its JSON-safe output on replay. External effects are
|
|
30
|
+
* at-least-once: an interruption after the effect starts is outcome-unknown,
|
|
31
|
+
* never silently retried.
|
|
32
|
+
*/
|
|
33
|
+
step<T>(name: string, fn: () => Promise<T>, options?: HarnessStepOptions): Promise<T>;
|
|
34
|
+
/** True only when this exact durable step has committed its memoized output. */
|
|
35
|
+
isCompleted(name: string, options?: Pick<HarnessStepOptions, "input" | "version">): boolean;
|
|
36
|
+
/** Allocates a run-scoped logical tool position independent of provider call IDs. */
|
|
37
|
+
nextToolCallOrdinal(toolName: string, toolCallId: string | undefined): number;
|
|
38
|
+
/** Associates this provider step's correlation IDs with its already-reserved durable positions. */
|
|
39
|
+
associateToolCallJournal(calls: readonly Readonly<{
|
|
40
|
+
toolName: string;
|
|
41
|
+
toolCallId: string;
|
|
42
|
+
}>[]): ReadonlyMap<string, string>;
|
|
43
|
+
/** Reconciles harness-owned journal markers with durable message history. */
|
|
44
|
+
reconcileToolCallJournal(journalMarkers: ReadonlySet<string>): void;
|
|
45
|
+
}
|
|
46
|
+
/** Private durable marker that never shares the tool-output value channel. */
|
|
47
|
+
export declare class HarnessStepOutputSerializationError extends Error {
|
|
48
|
+
constructor();
|
|
49
|
+
}
|
|
50
|
+
export declare function isToolOutputSerializationFailure(value: unknown): boolean;
|
|
51
|
+
export declare function createHarnessStepContext(ports: AgentHarnessPorts, options: HarnessStepContextOptions): HarnessStepContext;
|
|
52
|
+
//# sourceMappingURL=steps.d.ts.map
|