@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,404 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { tool } from "ai";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
const DEFAULT_MAX_FILE_BYTES = 64 * 1024;
|
|
5
|
+
const DEFAULT_MAX_WORKSPACE_BYTES = 1024 * 1024;
|
|
6
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024;
|
|
7
|
+
const DEFAULT_MAX_RESULTS = 100;
|
|
8
|
+
const MAX_PATH_BYTES = 128;
|
|
9
|
+
const SKILL_PATH = /^skills\/([^/]+)\/SKILL\.md$/;
|
|
10
|
+
const TOOL_MUTATION_RESULT = Symbol("tool-mutation-result");
|
|
11
|
+
const TOOL_WORKSPACE_CAPABILITY = Symbol("tool-workspace-capability");
|
|
12
|
+
const workspaceOutputBounds = new WeakMap();
|
|
13
|
+
function one(rows) {
|
|
14
|
+
return rows[0];
|
|
15
|
+
}
|
|
16
|
+
function boundedPositiveInteger(value, fallback, name) {
|
|
17
|
+
const selected = value ?? fallback;
|
|
18
|
+
if (!Number.isSafeInteger(selected) || selected < 1)
|
|
19
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
20
|
+
return selected;
|
|
21
|
+
}
|
|
22
|
+
function normalizePath(input) {
|
|
23
|
+
if (typeof input !== "string" || !input || input.startsWith("/") || input.includes("\\") || input.includes("\0")) {
|
|
24
|
+
throw new Error("workspace path is invalid");
|
|
25
|
+
}
|
|
26
|
+
const segments = [];
|
|
27
|
+
for (const segment of input.split("/")) {
|
|
28
|
+
if (!segment || segment === "..")
|
|
29
|
+
throw new Error("workspace path is invalid");
|
|
30
|
+
if (segment !== ".")
|
|
31
|
+
segments.push(segment);
|
|
32
|
+
}
|
|
33
|
+
if (segments.length === 0)
|
|
34
|
+
throw new Error("workspace path is invalid");
|
|
35
|
+
const normalized = segments.join("/");
|
|
36
|
+
if (Buffer.byteLength(normalized, "utf8") > MAX_PATH_BYTES)
|
|
37
|
+
throw new Error("workspace path exceeds configured bound");
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
function requireText(value, name) {
|
|
41
|
+
if (typeof value !== "string" || value.includes("\0"))
|
|
42
|
+
throw new Error(`${name} must be text`);
|
|
43
|
+
}
|
|
44
|
+
function boundedText(value, maxBytes, name) {
|
|
45
|
+
requireText(value, name);
|
|
46
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
47
|
+
if (bytes > maxBytes)
|
|
48
|
+
throw new Error(`${name} exceeds configured bound`);
|
|
49
|
+
return bytes;
|
|
50
|
+
}
|
|
51
|
+
function truncateUtf8(value, maxBytes) {
|
|
52
|
+
let output = "";
|
|
53
|
+
for (const codePoint of value) {
|
|
54
|
+
if (Buffer.byteLength(output, "utf8") + Buffer.byteLength(codePoint, "utf8") > maxBytes)
|
|
55
|
+
break;
|
|
56
|
+
output += codePoint;
|
|
57
|
+
}
|
|
58
|
+
return output;
|
|
59
|
+
}
|
|
60
|
+
function takeBounded(values, maxBytes) {
|
|
61
|
+
const selected = [];
|
|
62
|
+
let usedBytes = 0;
|
|
63
|
+
for (const value of values) {
|
|
64
|
+
const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
65
|
+
if (bytes > maxBytes || usedBytes + bytes > maxBytes) {
|
|
66
|
+
return Object.freeze({ values: Object.freeze(selected), truncated: true });
|
|
67
|
+
}
|
|
68
|
+
selected.push(value);
|
|
69
|
+
usedBytes += bytes;
|
|
70
|
+
}
|
|
71
|
+
return Object.freeze({ values: Object.freeze(selected) });
|
|
72
|
+
}
|
|
73
|
+
function boundedResult(value, maxBytes) {
|
|
74
|
+
if (Buffer.byteLength(JSON.stringify(value), "utf8") > maxBytes) {
|
|
75
|
+
throw new Error("workspace output exceeds configured bound");
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* COMPUTE-902: Calculate the maximum content bytes that can fit inside a
|
|
81
|
+
* serialized read envelope without exceeding the output budget.
|
|
82
|
+
*
|
|
83
|
+
* When `includeTruncated` is true, the budget accounts for the
|
|
84
|
+
* `,"truncated":true` field (17 bytes). When false, it assumes no
|
|
85
|
+
* truncated field is present (content fits fully).
|
|
86
|
+
*/
|
|
87
|
+
function readContentBudget(entry, maxOutputBytes, includeTruncated) {
|
|
88
|
+
if (maxOutputBytes <= 0)
|
|
89
|
+
return 0;
|
|
90
|
+
// Measure overhead with null content. JSON.stringify(null) === "null" (4 bytes).
|
|
91
|
+
const envelope = JSON.stringify({ ...entry, content: null });
|
|
92
|
+
const overhead = Buffer.byteLength(envelope, "utf8") - 4;
|
|
93
|
+
const truncatedReserve = includeTruncated ? 17 : 0; // ,"truncated":true
|
|
94
|
+
return Math.max(0, maxOutputBytes - overhead - truncatedReserve);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* COMPUTE-902/903 followup: Iteratively truncate content so the JSON-serialized
|
|
98
|
+
* read envelope fits within maxOutputBytes.
|
|
99
|
+
*
|
|
100
|
+
* Characters like `"`, `\`, and control codes expand during JSON encoding
|
|
101
|
+
* (e.g. `"` → `\"`), so raw-byte truncation alone is insufficient. This
|
|
102
|
+
* function builds the serialized envelope, measures its actual byte length,
|
|
103
|
+
* and shrinks the content until the serialized form fits the budget.
|
|
104
|
+
*
|
|
105
|
+
* Returns the truncated content and whether truncation occurred.
|
|
106
|
+
*/
|
|
107
|
+
function readBoundedContent(entry, content, maxOutputBytes) {
|
|
108
|
+
// Phase 1: Try full content without the truncated flag.
|
|
109
|
+
const fullResult = Object.freeze({ ...entry, content });
|
|
110
|
+
if (Buffer.byteLength(JSON.stringify(fullResult), "utf8") <= maxOutputBytes) {
|
|
111
|
+
return fullResult;
|
|
112
|
+
}
|
|
113
|
+
// Phase 2: Content needs truncation. Iteratively shrink until the
|
|
114
|
+
// serialized envelope (with the truncated:true field) fits.
|
|
115
|
+
const truncatedFlag = Object.freeze({ ...entry, content: "", truncated: true });
|
|
116
|
+
const minOverhead = Buffer.byteLength(JSON.stringify(truncatedFlag), "utf8");
|
|
117
|
+
if (minOverhead > maxOutputBytes) {
|
|
118
|
+
// Even empty content with the truncated flag exceeds the budget.
|
|
119
|
+
throw new Error("workspace output exceeds configured bound");
|
|
120
|
+
}
|
|
121
|
+
// Binary search for the largest content prefix that fits the serialized
|
|
122
|
+
// envelope. The raw-byte budget is an upper bound; the actual fitting size
|
|
123
|
+
// may be smaller because JSON escaping expands characters like " and \.
|
|
124
|
+
let lo = 0;
|
|
125
|
+
let hi = readContentBudget(entry, maxOutputBytes, true);
|
|
126
|
+
// Check whether any non-empty prefix fits before entering the search.
|
|
127
|
+
if (hi > 0) {
|
|
128
|
+
const hiTruncated = truncateUtf8(content, hi);
|
|
129
|
+
const hiCandidate = Object.freeze({ ...entry, content: hiTruncated, truncated: true });
|
|
130
|
+
if (Buffer.byteLength(JSON.stringify(hiCandidate), "utf8") <= maxOutputBytes) {
|
|
131
|
+
return hiCandidate;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Binary search: lo always fits (or is 0), hi never fits (or is 0).
|
|
135
|
+
while (lo < hi) {
|
|
136
|
+
const mid = lo + Math.floor((hi - lo + 1) / 2);
|
|
137
|
+
const midTruncated = truncateUtf8(content, mid);
|
|
138
|
+
const midCandidate = Object.freeze({ ...entry, content: midTruncated, truncated: true });
|
|
139
|
+
if (Buffer.byteLength(JSON.stringify(midCandidate), "utf8") <= maxOutputBytes) {
|
|
140
|
+
lo = mid; // mid fits — try larger
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
hi = mid - 1; // mid doesn't fit — try smaller
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (lo > 0) {
|
|
147
|
+
const truncated = truncateUtf8(content, lo);
|
|
148
|
+
return Object.freeze({ ...entry, content: truncated, truncated: true });
|
|
149
|
+
}
|
|
150
|
+
// No non-empty content slice fit. Return the empty truncated envelope if
|
|
151
|
+
// it fits the budget (exact-fit case); only throw if it does not.
|
|
152
|
+
if (minOverhead <= maxOutputBytes) {
|
|
153
|
+
return truncatedFlag;
|
|
154
|
+
}
|
|
155
|
+
throw new Error("workspace output exceeds configured bound");
|
|
156
|
+
}
|
|
157
|
+
function boundedToolResult(workspace, value) {
|
|
158
|
+
return boundedResult(value, workspaceOutputBounds.get(workspace) ?? DEFAULT_MAX_OUTPUT_BYTES);
|
|
159
|
+
}
|
|
160
|
+
function toolMutationOptions(expectedRevision) {
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
163
|
+
[TOOL_MUTATION_RESULT]: true,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function fileEntry(row) {
|
|
167
|
+
return Object.freeze({ path: row.path, bytes: row.bytes, revision: row.revision });
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Persistent, actor-local text workspace over the existing private harness SQL
|
|
171
|
+
* port. It intentionally exposes no filesystem, process, module-loading, or
|
|
172
|
+
* dynamic-extension capability.
|
|
173
|
+
*/
|
|
174
|
+
export function createVirtualWorkspace(ports, limits = {}) {
|
|
175
|
+
const maxFileBytes = boundedPositiveInteger(limits.maxFileBytes, DEFAULT_MAX_FILE_BYTES, "maxFileBytes");
|
|
176
|
+
const maxWorkspaceBytes = boundedPositiveInteger(limits.maxWorkspaceBytes, DEFAULT_MAX_WORKSPACE_BYTES, "maxWorkspaceBytes");
|
|
177
|
+
const maxOutputBytes = boundedPositiveInteger(limits.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES, "maxOutputBytes");
|
|
178
|
+
const maxResults = boundedPositiveInteger(limits.maxResults, DEFAULT_MAX_RESULTS, "maxResults");
|
|
179
|
+
ports.sql.transactionSync(() => {
|
|
180
|
+
ports.sql.exec("CREATE TABLE IF NOT EXISTS __telnyx_agent_harness_workspace_files (path TEXT PRIMARY KEY NOT NULL, content TEXT NOT NULL, bytes INTEGER NOT NULL CHECK(bytes >= 0), revision INTEGER NOT NULL CHECK(revision > 0))");
|
|
181
|
+
ports.sql.exec("CREATE TABLE IF NOT EXISTS __telnyx_agent_harness_workspace_journal (sequence INTEGER PRIMARY KEY AUTOINCREMENT, operation TEXT NOT NULL CHECK(operation IN ('write','edit')), path TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision > 0), bytes INTEGER NOT NULL CHECK(bytes >= 0))");
|
|
182
|
+
});
|
|
183
|
+
const get = (path) => one(ports.sql.exec("SELECT path,content,bytes,revision FROM __telnyx_agent_harness_workspace_files WHERE path = ?", path).toArray());
|
|
184
|
+
const write = async (inputPath, content, options = {}) => {
|
|
185
|
+
const path = normalizePath(inputPath);
|
|
186
|
+
const bytes = boundedText(content, maxFileBytes, "workspace file") + Buffer.byteLength(path, "utf8");
|
|
187
|
+
return ports.sql.transactionSync(() => {
|
|
188
|
+
const existing = get(path);
|
|
189
|
+
if (options.expectedRevision !== undefined && (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision < 1 || existing?.revision !== options.expectedRevision)) {
|
|
190
|
+
throw new Error("workspace revision conflict");
|
|
191
|
+
}
|
|
192
|
+
const total = one(ports.sql.exec("SELECT COALESCE(SUM(bytes), 0) AS total FROM __telnyx_agent_harness_workspace_files WHERE path <> ?", path).toArray())?.total ?? 0;
|
|
193
|
+
if (total + bytes > maxWorkspaceBytes)
|
|
194
|
+
throw new Error("workspace quota exceeded");
|
|
195
|
+
const revision = (existing?.revision ?? 0) + 1;
|
|
196
|
+
const entry = boundedResult(Object.freeze({ path, bytes, revision }), maxOutputBytes);
|
|
197
|
+
const result = options[TOOL_MUTATION_RESULT]
|
|
198
|
+
? boundedToolResult(workspace, Object.freeze({ origin: "workspace", ...entry }))
|
|
199
|
+
: entry;
|
|
200
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_workspace_files(path,content,bytes,revision) VALUES (?,?,?,?) ON CONFLICT(path) DO UPDATE SET content=excluded.content,bytes=excluded.bytes,revision=excluded.revision", path, content, bytes, revision);
|
|
201
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_workspace_journal(operation,path,revision,bytes) VALUES ('write',?,?,?)", path, revision, bytes);
|
|
202
|
+
return result;
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
const workspace = Object.freeze({
|
|
206
|
+
[TOOL_WORKSPACE_CAPABILITY]: true,
|
|
207
|
+
async read(inputPath) {
|
|
208
|
+
const row = get(normalizePath(inputPath));
|
|
209
|
+
if (row === undefined)
|
|
210
|
+
return undefined;
|
|
211
|
+
// COMPUTE-902/903 followup: Budget reads against the serialized JSON
|
|
212
|
+
// envelope, not raw content bytes. Characters like " and \ expand
|
|
213
|
+
// during JSON encoding, so raw-byte budgeting alone can return a
|
|
214
|
+
// payload whose serialized form exceeds maxOutputBytes.
|
|
215
|
+
const entry = fileEntry(row);
|
|
216
|
+
return readBoundedContent(entry, row.content, maxOutputBytes);
|
|
217
|
+
},
|
|
218
|
+
write,
|
|
219
|
+
async edit(inputPath, oldText, newText, options = {}) {
|
|
220
|
+
const path = normalizePath(inputPath);
|
|
221
|
+
requireText(oldText, "edit source");
|
|
222
|
+
requireText(newText, "edit replacement");
|
|
223
|
+
if (!oldText)
|
|
224
|
+
throw new Error("edit source must not be empty");
|
|
225
|
+
return ports.sql.transactionSync(() => {
|
|
226
|
+
const existing = get(path);
|
|
227
|
+
if (existing === undefined)
|
|
228
|
+
throw new Error("workspace file does not exist");
|
|
229
|
+
if (options.expectedRevision !== undefined && existing.revision !== options.expectedRevision)
|
|
230
|
+
throw new Error("workspace revision conflict");
|
|
231
|
+
const first = existing.content.indexOf(oldText);
|
|
232
|
+
if (first < 0)
|
|
233
|
+
throw new Error("edit source was not found");
|
|
234
|
+
if (existing.content.indexOf(oldText, first + 1) >= 0)
|
|
235
|
+
throw new Error("edit source is ambiguous");
|
|
236
|
+
const content = `${existing.content.slice(0, first)}${newText}${existing.content.slice(first + oldText.length)}`;
|
|
237
|
+
const bytes = boundedText(content, maxFileBytes, "workspace file") + Buffer.byteLength(path, "utf8");
|
|
238
|
+
const total = one(ports.sql.exec("SELECT COALESCE(SUM(bytes), 0) AS total FROM __telnyx_agent_harness_workspace_files WHERE path <> ?", path).toArray())?.total ?? 0;
|
|
239
|
+
if (total + bytes > maxWorkspaceBytes)
|
|
240
|
+
throw new Error("workspace quota exceeded");
|
|
241
|
+
const revision = existing.revision + 1;
|
|
242
|
+
const entry = boundedResult(Object.freeze({ path, bytes, revision }), maxOutputBytes);
|
|
243
|
+
const result = options[TOOL_MUTATION_RESULT]
|
|
244
|
+
? boundedToolResult(workspace, Object.freeze({ origin: "workspace", ...entry }))
|
|
245
|
+
: entry;
|
|
246
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_workspace_files SET content = ?,bytes = ?,revision = ? WHERE path = ?", content, bytes, revision, path);
|
|
247
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_workspace_journal(operation,path,revision,bytes) VALUES ('edit',?,?,?)", path, revision, bytes);
|
|
248
|
+
return result;
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
async list() {
|
|
252
|
+
const rows = ports.sql.exec("SELECT path,content,bytes,revision FROM __telnyx_agent_harness_workspace_files ORDER BY path LIMIT ?", maxResults + 1).toArray();
|
|
253
|
+
const bounded = takeBounded(rows.slice(0, maxResults).map(fileEntry), maxOutputBytes);
|
|
254
|
+
return boundedResult(Object.freeze({ entries: bounded.values, ...(rows.length > maxResults || bounded.truncated ? { truncated: true } : {}) }), maxOutputBytes);
|
|
255
|
+
},
|
|
256
|
+
async find(needle) {
|
|
257
|
+
requireText(needle, "find query");
|
|
258
|
+
if (!needle)
|
|
259
|
+
throw new Error("find query must not be empty");
|
|
260
|
+
const rows = ports.sql.exec("SELECT path,content,bytes,revision FROM __telnyx_agent_harness_workspace_files WHERE instr(content, ?) > 0 ORDER BY path LIMIT ?", needle, maxResults + 1).toArray();
|
|
261
|
+
const bounded = takeBounded(rows.slice(0, maxResults).map(fileEntry), maxOutputBytes);
|
|
262
|
+
return boundedResult(Object.freeze({ matches: bounded.values, ...(rows.length > maxResults || bounded.truncated ? { truncated: true } : {}) }), maxOutputBytes);
|
|
263
|
+
},
|
|
264
|
+
async grep(needle) {
|
|
265
|
+
requireText(needle, "grep query");
|
|
266
|
+
if (!needle)
|
|
267
|
+
throw new Error("grep query must not be empty");
|
|
268
|
+
const rows = ports.sql.exec("SELECT path,content,bytes,revision FROM __telnyx_agent_harness_workspace_files WHERE instr(content, ?) > 0 ORDER BY path", needle).toArray();
|
|
269
|
+
const matches = [];
|
|
270
|
+
for (const row of rows) {
|
|
271
|
+
for (const [index, line] of row.content.split("\n").entries()) {
|
|
272
|
+
if (!line.includes(needle))
|
|
273
|
+
continue;
|
|
274
|
+
const candidate = Object.freeze({ path: row.path, line: index + 1, text: truncateUtf8(line, maxOutputBytes) });
|
|
275
|
+
const bounded = takeBounded([...matches, candidate], maxOutputBytes);
|
|
276
|
+
if (bounded.truncated || matches.length >= maxResults)
|
|
277
|
+
return boundedResult(Object.freeze({ matches: Object.freeze(matches), truncated: true }), maxOutputBytes);
|
|
278
|
+
matches.push(candidate);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return boundedResult(Object.freeze({ matches: Object.freeze(matches) }), maxOutputBytes);
|
|
282
|
+
},
|
|
283
|
+
async journal(options = {}) {
|
|
284
|
+
const limit = boundedPositiveInteger(options.limit, maxResults, "journal limit");
|
|
285
|
+
if (limit > maxResults)
|
|
286
|
+
throw new Error("journal limit exceeds configured bound");
|
|
287
|
+
const rows = ports.sql.exec("SELECT sequence,operation,path,revision,bytes FROM __telnyx_agent_harness_workspace_journal ORDER BY sequence LIMIT ?", limit).toArray().map((row) => Object.freeze(row));
|
|
288
|
+
const bounded = takeBounded(rows, maxOutputBytes);
|
|
289
|
+
if (bounded.truncated)
|
|
290
|
+
throw new Error("journal output exceeds configured bound");
|
|
291
|
+
return boundedResult(Object.freeze(bounded.values), maxOutputBytes);
|
|
292
|
+
},
|
|
293
|
+
async discoverSkills() {
|
|
294
|
+
const rows = ports.sql.exec("SELECT path,content,bytes,revision FROM __telnyx_agent_harness_workspace_files WHERE path LIKE 'skills/%' ORDER BY path").toArray();
|
|
295
|
+
const skills = rows.flatMap((row) => {
|
|
296
|
+
const match = SKILL_PATH.exec(row.path);
|
|
297
|
+
return match === null ? [] : [Object.freeze({ name: match[1], path: row.path, origin: "workspace" })];
|
|
298
|
+
});
|
|
299
|
+
const selected = [];
|
|
300
|
+
for (const [index, skill] of skills.entries()) {
|
|
301
|
+
if (index >= maxResults)
|
|
302
|
+
break;
|
|
303
|
+
const truncated = index < skills.length - 1 || index + 1 >= maxResults && skills.length > maxResults;
|
|
304
|
+
const candidate = Object.freeze({ skills: Object.freeze([...selected, skill]), ...(truncated ? { truncated: true } : {}) });
|
|
305
|
+
if (Buffer.byteLength(JSON.stringify(candidate), "utf8") > maxOutputBytes)
|
|
306
|
+
break;
|
|
307
|
+
selected.push(skill);
|
|
308
|
+
}
|
|
309
|
+
const truncated = selected.length < skills.length;
|
|
310
|
+
return boundedResult(Object.freeze({ skills: Object.freeze(selected), ...(truncated ? { truncated: true } : {}) }), maxOutputBytes);
|
|
311
|
+
},
|
|
312
|
+
async activateSkill(name) {
|
|
313
|
+
if (typeof name !== "string" || !name || name.includes("/") || name.includes("\\") || name.includes("\0"))
|
|
314
|
+
throw new Error("skill name is invalid");
|
|
315
|
+
const path = `skills/${name}/SKILL.md`;
|
|
316
|
+
const row = get(path);
|
|
317
|
+
if (row === undefined)
|
|
318
|
+
throw new Error("unknown skill");
|
|
319
|
+
boundedText(row.content, maxOutputBytes, "skill content");
|
|
320
|
+
return boundedResult(Object.freeze({ name, path, origin: "activated-skill", content: row.content }), maxOutputBytes);
|
|
321
|
+
},
|
|
322
|
+
async assemblePrompt(base, names) {
|
|
323
|
+
requireText(base, "application prompt");
|
|
324
|
+
const sources = [Object.freeze({ origin: "application" })];
|
|
325
|
+
const contents = [base];
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const active = await workspace.activateSkill(name);
|
|
328
|
+
sources.push(Object.freeze({ origin: "activated-skill", name: active.name, path: active.path }));
|
|
329
|
+
contents.push(active.content);
|
|
330
|
+
}
|
|
331
|
+
const text = contents.join("\n\n");
|
|
332
|
+
// COMPUTE-903: The model-visible text is bounded against maxOutputBytes
|
|
333
|
+
// because that is what the model consumes. The provenance-only sources
|
|
334
|
+
// carry no duplicated source bodies.
|
|
335
|
+
// COMPUTE-902/903 followup: The serialized { text, sources } envelope must
|
|
336
|
+
// also honor maxOutputBytes. Source metadata (origin, name, path) has a
|
|
337
|
+
// non-trivial serialized size, so we split the budget: measure the sources
|
|
338
|
+
// overhead, then bound the text to maxOutputBytes minus that overhead.
|
|
339
|
+
// This preserves COMPUTE-903 (no double-counting of source *content*)
|
|
340
|
+
// while enforcing the serialized output contract.
|
|
341
|
+
const sourcesOverhead = Buffer.byteLength(JSON.stringify(Object.freeze({ text: "", sources: Object.freeze(sources) })), "utf8");
|
|
342
|
+
if (sourcesOverhead > maxOutputBytes) {
|
|
343
|
+
// Source metadata alone exceeds the budget — cannot fit any text.
|
|
344
|
+
throw new Error("workspace output exceeds configured bound");
|
|
345
|
+
}
|
|
346
|
+
const textBudget = maxOutputBytes - sourcesOverhead;
|
|
347
|
+
boundedText(text, textBudget, "prompt");
|
|
348
|
+
return boundedResult(Object.freeze({ text, sources: Object.freeze(sources) }), maxOutputBytes);
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
workspaceOutputBounds.set(workspace, maxOutputBytes);
|
|
352
|
+
return workspace;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Explicit server-side tools backed only by a {@link VirtualWorkspace}. Skill
|
|
356
|
+
* documents remain returned data; this factory never evaluates their content.
|
|
357
|
+
*/
|
|
358
|
+
export function createVirtualWorkspaceTools(workspace) {
|
|
359
|
+
if (!workspaceOutputBounds.has(workspace)) {
|
|
360
|
+
throw new TypeError("virtual workspace tools require a concrete workspace");
|
|
361
|
+
}
|
|
362
|
+
const path = z.string().min(1);
|
|
363
|
+
return Object.freeze({
|
|
364
|
+
read_file: tool({
|
|
365
|
+
description: "Read a bounded text file from the actor-local virtual workspace.",
|
|
366
|
+
inputSchema: z.object({ path }),
|
|
367
|
+
execute: async ({ path: inputPath }) => {
|
|
368
|
+
const canonicalPath = normalizePath(inputPath);
|
|
369
|
+
return boundedToolResult(workspace, Object.freeze({ origin: "workspace", ...(await workspace.read(canonicalPath) ?? { path: canonicalPath, found: false }) }));
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
write_file: tool({
|
|
373
|
+
description: "Write bounded text to an actor-local virtual workspace file.",
|
|
374
|
+
inputSchema: z.object({ path, content: z.string(), expectedRevision: z.number().int().positive().optional() }),
|
|
375
|
+
execute: async ({ path: inputPath, content, expectedRevision }) => workspace.write(inputPath, content, toolMutationOptions(expectedRevision)),
|
|
376
|
+
}),
|
|
377
|
+
edit_file: tool({
|
|
378
|
+
description: "Replace exactly one text occurrence in an actor-local virtual workspace file.",
|
|
379
|
+
inputSchema: z.object({ path, oldText: z.string().min(1), newText: z.string(), expectedRevision: z.number().int().positive().optional() }),
|
|
380
|
+
execute: async ({ path: inputPath, oldText, newText, expectedRevision }) => workspace.edit(inputPath, oldText, newText, toolMutationOptions(expectedRevision)),
|
|
381
|
+
}),
|
|
382
|
+
list_files: tool({
|
|
383
|
+
description: "List actor-local virtual workspace files in deterministic path order.",
|
|
384
|
+
inputSchema: z.object({}),
|
|
385
|
+
execute: async () => boundedToolResult(workspace, Object.freeze({ origin: "workspace", ...(await workspace.list()) })),
|
|
386
|
+
}),
|
|
387
|
+
find_files: tool({
|
|
388
|
+
description: "Find text in actor-local virtual workspace files in deterministic path order.",
|
|
389
|
+
inputSchema: z.object({ needle: z.string().min(1) }),
|
|
390
|
+
execute: async ({ needle }) => boundedToolResult(workspace, Object.freeze({ origin: "workspace", ...(await workspace.find(needle)) })),
|
|
391
|
+
}),
|
|
392
|
+
grep_files: tool({
|
|
393
|
+
description: "Find matching text lines in actor-local virtual workspace files without evaluating patterns.",
|
|
394
|
+
inputSchema: z.object({ needle: z.string().min(1) }),
|
|
395
|
+
execute: async ({ needle }) => boundedToolResult(workspace, Object.freeze({ origin: "workspace", ...(await workspace.grep(needle)) })),
|
|
396
|
+
}),
|
|
397
|
+
activate_skill: tool({
|
|
398
|
+
description: "Explicitly load one inert SKILL.md document as prompt data with its provenance.",
|
|
399
|
+
inputSchema: z.object({ name: z.string().min(1) }),
|
|
400
|
+
execute: async ({ name }) => boundedToolResult(workspace, await workspace.activateSkill(name)),
|
|
401
|
+
}),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
//# sourceMappingURL=workspace.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@telnyx/agent-harness",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Telnyx Agent Harness beta: portable ports, bounded turns, and Node test-host adapter.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.13.0"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./contract": {
|
|
18
|
+
"types": "./dist/contract.d.ts",
|
|
19
|
+
"import": "./dist/contract.js"
|
|
20
|
+
},
|
|
21
|
+
"./node": {
|
|
22
|
+
"types": "./dist/node-adapter.d.ts",
|
|
23
|
+
"import": "./dist/node-adapter.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/**/*.js",
|
|
28
|
+
"dist/**/*.d.ts",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc && node ../../scripts/build-agent-harness-node.mjs",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"lint": "tsc -p tsconfig.lint.json",
|
|
35
|
+
"prepack": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@ai-sdk/gateway": "3.0.181",
|
|
39
|
+
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
40
|
+
"@telnyx/edge-runtime": "^0.15.2",
|
|
41
|
+
"ai": "6.0.266",
|
|
42
|
+
"zod": "4.3.6"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@telnyx/ai-sdk-provider": "1.0.0",
|
|
46
|
+
"@telnyx/runtime-internal-tbd": "*",
|
|
47
|
+
"esbuild": "^0.28.1",
|
|
48
|
+
"vitest": "^4.1.6"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"vitest": "^4.1.6"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"vitest": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|