@devosurf/tesser-sdk 0.1.0-alpha.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.
@@ -0,0 +1,398 @@
1
+ // Static manifest extraction (ADR-0010 / 0012): the build reads an automation's or
2
+ // connector's requirements WITHOUT executing handlers. Module evaluation is allowed;
3
+ // `run` functions are never called. Powers deploy-halt, connect links, codegen, and CI.
4
+
5
+ import type { AutomationDef, ConnectionMap, RetryPolicy, SecretMap } from "../automation.js";
6
+ import type { HarnessDef } from "../harnesses.js";
7
+ import type { ModelDef, ModelSettingsV1, OperatorDef } from "../operators.js";
8
+ import type {
9
+ ActionsTree,
10
+ AnyAction,
11
+ ConnectorInstance,
12
+ ProviderFacts,
13
+ TriggersDecl,
14
+ } from "../connector/index.js";
15
+ import { isAction, isConnector } from "../connector/index.js";
16
+ import type {
17
+ ConnectorTrigger,
18
+ EventTrigger,
19
+ ScheduleTrigger,
20
+ Trigger,
21
+ WebhookTrigger,
22
+ } from "../triggers.js";
23
+ import { encodeJournal, type JsonValue } from "./codec.js";
24
+ import { actionAtPath, isRetrySafe } from "./client.js";
25
+
26
+ export type TriggerManifest =
27
+ | { kind: "schedule"; cron: string; tz?: string }
28
+ | { kind: "webhook"; respond: "async" | "sync"; hasInputSchema: boolean }
29
+ | { kind: "event"; event: string }
30
+ | {
31
+ kind: "connector";
32
+ connector: string;
33
+ trigger: string;
34
+ params: JsonValue;
35
+ /** The `connections` key this trigger binds to (resolved here, ADR-0013). */
36
+ connection: string;
37
+ /** Author cadence override for poll triggers (clamped to the connector floor). */
38
+ every?: string;
39
+ };
40
+
41
+ export interface AutomationManifest {
42
+ id: string;
43
+ trigger: TriggerManifest;
44
+ connections: Record<string, { connector: string; scope: "workspace" | "per_user" }>;
45
+ secrets: Record<string, { describe?: string }>;
46
+ models: Record<string, { connection: string; connector: string; alias: string; settings?: ModelSettingsV1 }>;
47
+ operators: Record<
48
+ string,
49
+ {
50
+ model: string;
51
+ instructions: string;
52
+ tools: Array<{ path: string; connection: string; action: string; safety: "read" | "write" }>;
53
+ maxTurns: number;
54
+ }
55
+ >;
56
+ harnesses: Record<
57
+ string,
58
+ {
59
+ connection: string;
60
+ connector: string;
61
+ sandbox: string;
62
+ permissions: string;
63
+ timeout?: string;
64
+ maxOutputBytes?: number;
65
+ }
66
+ >;
67
+ budget?: { models?: { tokens: number; outputTokens: number }; harnesses?: { invocations: number; wallClock?: string } };
68
+ retry?: RetryPolicy;
69
+ concurrency?: { limit: number; hasKey: boolean; onConflict: "queue" | "drop" };
70
+ hasInputSchema: boolean;
71
+ hasOutputSchema: boolean;
72
+ }
73
+
74
+ export class ManifestError extends TypeError {
75
+ constructor(
76
+ readonly automationId: string,
77
+ message: string,
78
+ ) {
79
+ super(`automation "${automationId}": ${message}`);
80
+ this.name = "ManifestError";
81
+ }
82
+ }
83
+
84
+ // `any` maps on purpose: Ctx is contravariant in `run`, so a concretely-typed def is not
85
+ // assignable to AutomationDef<…, ConnectionMap, SecretMap>.
86
+ export function extractAutomationManifest(
87
+ def: AutomationDef<any, any, any, any, any, any, any>,
88
+ ): AutomationManifest {
89
+ const connections: AutomationManifest["connections"] = {};
90
+ const connMap = (def.connections ?? {}) as ConnectionMap;
91
+ for (const [key, conn] of Object.entries(connMap)) {
92
+ connections[key] = { connector: conn.id, scope: conn.scope ?? "workspace" };
93
+ }
94
+ const secrets: AutomationManifest["secrets"] = {};
95
+ const secretMap = (def.secrets ?? {}) as SecretMap;
96
+ for (const [key, sec] of Object.entries(secretMap)) {
97
+ secrets[key] = sec.describe !== undefined ? { describe: sec.describe } : {};
98
+ }
99
+
100
+ let trigger: TriggerManifest;
101
+ const t = def.trigger as Trigger<unknown>;
102
+ switch (t.kind) {
103
+ case "schedule": {
104
+ const st = t as ScheduleTrigger;
105
+ trigger = { kind: "schedule", cron: st.cron, ...(st.tz !== undefined ? { tz: st.tz } : {}) };
106
+ break;
107
+ }
108
+ case "webhook": {
109
+ const wt = t as WebhookTrigger<unknown>;
110
+ trigger = { kind: "webhook", respond: wt.respond, hasInputSchema: wt.input !== undefined };
111
+ break;
112
+ }
113
+ case "event": {
114
+ const et = t as EventTrigger<unknown>;
115
+ trigger = { kind: "event", event: et.event.name };
116
+ break;
117
+ }
118
+ case "connector": {
119
+ const ct = t as ConnectorTrigger<unknown>;
120
+ // Connection binding (ADR-0013): explicit key, else auto-bind when exactly one
121
+ // connections entry uses this connector.
122
+ let key = ct.connectionKey;
123
+ if (key !== undefined) {
124
+ const bound = connections[key];
125
+ if (!bound) {
126
+ throw new ManifestError(def.id, `trigger names connection "${key}" but connections has no such entry`);
127
+ }
128
+ if (bound.connector !== ct.connectorId) {
129
+ throw new ManifestError(
130
+ def.id,
131
+ `trigger connection "${key}" is a ${bound.connector} connection, but the trigger belongs to ${ct.connectorId}`,
132
+ );
133
+ }
134
+ } else {
135
+ const candidates = Object.entries(connections).filter(([, c]) => c.connector === ct.connectorId);
136
+ if (candidates.length === 0) {
137
+ throw new ManifestError(
138
+ def.id,
139
+ `trigger ${ct.connectorId}.triggers.${ct.triggerId} requires a ${ct.connectorId} entry in connections`,
140
+ );
141
+ }
142
+ if (candidates.length > 1) {
143
+ throw new ManifestError(
144
+ def.id,
145
+ `trigger ${ct.connectorId}.triggers.${ct.triggerId} is ambiguous — name it: { connection: "<key>" } (candidates: ${candidates.map(([k]) => k).join(", ")})`,
146
+ );
147
+ }
148
+ key = (candidates[0] as [string, unknown])[0];
149
+ }
150
+ let params: JsonValue;
151
+ try {
152
+ params = encodeJournal(ct.params);
153
+ } catch (err) {
154
+ throw new ManifestError(def.id, `trigger params must be plain data: ${(err as Error).message}`);
155
+ }
156
+ trigger = {
157
+ kind: "connector",
158
+ connector: ct.connectorId,
159
+ trigger: ct.triggerId,
160
+ params,
161
+ connection: key,
162
+ ...(ct.every !== undefined ? { every: ct.every } : {}),
163
+ };
164
+ break;
165
+ }
166
+ default:
167
+ throw new ManifestError(def.id, `unknown trigger kind "${(t as { kind: string }).kind}"`);
168
+ }
169
+
170
+ const models: AutomationManifest["models"] = {};
171
+ const modelMap = (def.models ?? {}) as Record<string, ModelDef>;
172
+ for (const [key, m] of Object.entries(modelMap)) {
173
+ const conn = connections[m.connection];
174
+ if (!conn) {
175
+ throw new ManifestError(def.id, `models.${key} names connection "${m.connection}" but connections has no such entry`);
176
+ }
177
+ const connector = connMap[m.connection] as ConnectorInstance<any, any> | undefined;
178
+ if (!connector?.__connector.modelProvider) {
179
+ throw new ManifestError(def.id, `models.${key} uses connection "${m.connection}" but ${conn.connector} is not model-capable`);
180
+ }
181
+ models[key] = {
182
+ connection: m.connection,
183
+ connector: conn.connector,
184
+ alias: m.alias,
185
+ ...(m.settings !== undefined ? { settings: m.settings } : {}),
186
+ };
187
+ }
188
+
189
+ const operators: AutomationManifest["operators"] = {};
190
+ const operatorMap = (def.operators ?? {}) as Record<string, OperatorDef>;
191
+ for (const [key, op] of Object.entries(operatorMap)) {
192
+ if (!models[op.model]) {
193
+ throw new ManifestError(def.id, `operators.${key} references unknown model "${op.model}"`);
194
+ }
195
+ const tools: AutomationManifest["operators"][string]["tools"] = [];
196
+ for (const tool of op.tools) {
197
+ const [connKey, ...actionPath] = tool.split(".");
198
+ const conn = connections[connKey!];
199
+ if (!conn) {
200
+ throw new ManifestError(def.id, `operators.${key} tool "${tool}" names undeclared connection "${connKey}"`);
201
+ }
202
+ const connector = connMap[connKey!] as ConnectorInstance<any, any> | undefined;
203
+ const action = connector ? actionAtPath(connector, actionPath) : undefined;
204
+ if (!action) {
205
+ throw new ManifestError(def.id, `operators.${key} tool "${tool}" is not a declared Action`);
206
+ }
207
+ tools.push({ path: tool, connection: connKey!, action: actionPath.join("."), safety: action.safety });
208
+ }
209
+ operators[key] = { model: op.model, instructions: op.instructions, tools, maxTurns: op.maxTurns };
210
+ }
211
+ if (Object.keys(operators).length > 0 && !def.budget?.models) {
212
+ throw new ManifestError(def.id, `budget.models is required when operators are declared`);
213
+ }
214
+
215
+ const harnesses: AutomationManifest["harnesses"] = {};
216
+ const harnessMap = (def.harnesses ?? {}) as Record<string, HarnessDef>;
217
+ for (const [key, h] of Object.entries(harnessMap)) {
218
+ const conn = connections[h.connection];
219
+ if (!conn) {
220
+ throw new ManifestError(def.id, `harnesses.${key} names connection "${h.connection}" but connections has no such entry`);
221
+ }
222
+ const connector = connMap[h.connection] as ConnectorInstance<any, any> | undefined;
223
+ if (!connector?.__connector.harnessProvider) {
224
+ throw new ManifestError(def.id, `harnesses.${key} uses connection "${h.connection}" but ${conn.connector} is not harness-capable`);
225
+ }
226
+ harnesses[key] = {
227
+ connection: h.connection,
228
+ connector: conn.connector,
229
+ sandbox: h.sandbox,
230
+ permissions: h.permissions,
231
+ ...(h.timeout !== undefined ? { timeout: h.timeout } : {}),
232
+ ...(h.maxOutputBytes !== undefined ? { maxOutputBytes: h.maxOutputBytes } : {}),
233
+ };
234
+ }
235
+
236
+ return {
237
+ id: def.id,
238
+ trigger,
239
+ connections,
240
+ secrets,
241
+ models,
242
+ operators,
243
+ harnesses,
244
+ ...(def.budget !== undefined ? { budget: def.budget } : {}),
245
+ ...(def.retry !== undefined ? { retry: def.retry } : {}),
246
+ ...(def.concurrency !== undefined
247
+ ? {
248
+ concurrency: {
249
+ limit: def.concurrency.limit,
250
+ hasKey: typeof def.concurrency.key === "function",
251
+ onConflict: def.concurrency.onConflict ?? "queue",
252
+ },
253
+ }
254
+ : {}),
255
+ hasInputSchema: def.input !== undefined,
256
+ hasOutputSchema: def.output !== undefined,
257
+ };
258
+ }
259
+
260
+ // ---- Connector manifests (codegen + Catalog assembly, ADR-0012) ----
261
+
262
+ export interface ConnectorManifest {
263
+ id: string;
264
+ describe?: string;
265
+ provider?: string;
266
+ providerFacts?: ProviderFacts;
267
+ baseUrl?: string;
268
+ auth: Record<
269
+ string,
270
+ | { kind: "oauth2"; provider?: string; scopes: string[]; flow: string; describe?: string }
271
+ | { kind: "apiKey"; in: string; name: string; prefix?: string; describe?: string }
272
+ | { kind: "basic"; describe?: string }
273
+ | { kind: "custom"; fields: string[]; describe?: string }
274
+ >;
275
+ idempotencyHeader?: string;
276
+ actions: Array<{ path: string; describe?: string; safety: "read" | "write"; retrySafe: boolean }>;
277
+ modelProvider?: { aliases?: Record<string, string> };
278
+ harnessProvider?: { adapter: string };
279
+ triggers: Array<{
280
+ id: string;
281
+ strategy: "poll" | "webhook";
282
+ describe?: string;
283
+ registerMode?: "auto" | "manual";
284
+ intervalDefault?: string;
285
+ intervalFloor?: string;
286
+ event?: string;
287
+ }>;
288
+ webhookVerify?: string;
289
+ }
290
+
291
+ export function extractConnectorManifest(
292
+ connector: ConnectorInstance<ActionsTree, TriggersDecl>,
293
+ ): ConnectorManifest {
294
+ if (!isConnector(connector)) throw new TypeError("extractConnectorManifest: not a connector");
295
+ const spec = connector.__connector;
296
+
297
+ const authMap = ("kind" in spec.auth ? { default: spec.auth } : spec.auth) as Record<
298
+ string,
299
+ import("../connector/index.js").AuthDecl
300
+ >;
301
+ const auth: ConnectorManifest["auth"] = {};
302
+ for (const [mode, decl] of Object.entries(authMap)) {
303
+ switch (decl.kind) {
304
+ case "oauth2":
305
+ auth[mode] = {
306
+ kind: "oauth2",
307
+ scopes: [...decl.scopes],
308
+ flow: decl.flow,
309
+ ...(decl.provider !== undefined ? { provider: decl.provider } : {}),
310
+ ...(decl.describe !== undefined ? { describe: decl.describe } : {}),
311
+ };
312
+ break;
313
+ case "apiKey":
314
+ auth[mode] = {
315
+ kind: "apiKey",
316
+ in: decl.in,
317
+ name: decl.name,
318
+ ...(decl.prefix !== undefined ? { prefix: decl.prefix } : {}),
319
+ ...(decl.describe !== undefined ? { describe: decl.describe } : {}),
320
+ };
321
+ break;
322
+ case "basic":
323
+ auth[mode] = { kind: "basic", ...(decl.describe !== undefined ? { describe: decl.describe } : {}) };
324
+ break;
325
+ case "custom":
326
+ auth[mode] = {
327
+ kind: "custom",
328
+ fields: [...(decl.fields ?? [])],
329
+ ...(decl.describe !== undefined ? { describe: decl.describe } : {}),
330
+ };
331
+ break;
332
+ }
333
+ }
334
+
335
+ const actions: ConnectorManifest["actions"] = [];
336
+ const hasIdem = spec.idempotencyHeader !== undefined;
337
+ (function walk(tree: ActionsTree, path: string[]) {
338
+ for (const [key, child] of Object.entries(tree)) {
339
+ const p = [...path, key];
340
+ if (isAction(child)) {
341
+ const a = child as AnyAction;
342
+ actions.push({
343
+ path: p.join("."),
344
+ safety: a.safety,
345
+ retrySafe: isRetrySafe(a, hasIdem),
346
+ ...(a.describe !== undefined ? { describe: a.describe } : {}),
347
+ });
348
+ } else {
349
+ walk(child as ActionsTree, p);
350
+ }
351
+ }
352
+ })(spec.actions ?? {}, []);
353
+
354
+ const triggers: ConnectorManifest["triggers"] = [];
355
+ for (const [id, decl] of Object.entries(spec.triggers ?? {})) {
356
+ if (decl.__trigger === "webhook") {
357
+ triggers.push({
358
+ id,
359
+ strategy: "webhook",
360
+ registerMode: decl.register.mode,
361
+ event: decl.event,
362
+ ...(decl.describe !== undefined ? { describe: decl.describe } : {}),
363
+ });
364
+ } else {
365
+ triggers.push({
366
+ id,
367
+ strategy: "poll",
368
+ ...(decl.interval?.default !== undefined ? { intervalDefault: decl.interval.default } : {}),
369
+ ...(decl.interval?.floor !== undefined ? { intervalFloor: decl.interval.floor } : {}),
370
+ ...(decl.describe !== undefined ? { describe: decl.describe } : {}),
371
+ });
372
+ }
373
+ }
374
+
375
+ return {
376
+ id: spec.id,
377
+ auth,
378
+ actions,
379
+ ...(spec.modelProvider !== undefined
380
+ ? {
381
+ modelProvider: {
382
+ ...(spec.modelProvider.aliases !== undefined ? { aliases: spec.modelProvider.aliases } : {}),
383
+ },
384
+ }
385
+ : {}),
386
+ ...(spec.harnessProvider !== undefined ? { harnessProvider: { adapter: spec.harnessProvider.adapter } } : {}),
387
+ triggers,
388
+ ...(spec.describe !== undefined ? { describe: spec.describe } : {}),
389
+ ...(typeof spec.provider === "string"
390
+ ? { provider: spec.provider }
391
+ : spec.provider !== undefined
392
+ ? { provider: spec.provider.id, providerFacts: spec.provider }
393
+ : {}),
394
+ ...(spec.baseUrl !== undefined ? { baseUrl: spec.baseUrl } : {}),
395
+ ...(spec.idempotencyHeader !== undefined ? { idempotencyHeader: spec.idempotencyHeader } : {}),
396
+ ...(spec.webhook !== undefined ? { webhookVerify: spec.webhook.verify.kind } : {}),
397
+ };
398
+ }
@@ -0,0 +1,287 @@
1
+ import { TerminalError } from "../errors.js";
2
+ import type { AutomationDef, Ctx, Schema, Serializable } from "../automation.js";
3
+ import type { ActionsTree, AnyAction, ConnectorInstance, TriggersDecl } from "../connector/index.js";
4
+ import type {
5
+ ModelDef,
6
+ ModelMessage,
7
+ ModelToolCall,
8
+ ModelToolDescriptor,
9
+ NormalizedModelRequest,
10
+ NormalizedModelResponse,
11
+ OperatorDef,
12
+ Operators,
13
+ } from "../operators.js";
14
+ import { encodeJournal, decodeJournal, type JsonValue } from "./codec.js";
15
+ import { actionAtPath } from "./client.js";
16
+ import { validateSchema } from "./standard-schema.js";
17
+
18
+ export interface ModelCallInfo {
19
+ automationId: string;
20
+ operatorKey: string;
21
+ modelKey: string;
22
+ model: ModelDef;
23
+ request: NormalizedModelRequest;
24
+ }
25
+
26
+ export type ModelCaller = (info: ModelCallInfo) => Promise<NormalizedModelResponse>;
27
+
28
+ interface ToolRuntime {
29
+ descriptor: ModelToolDescriptor;
30
+ connectionKey: string;
31
+ actionPath: string[];
32
+ action: AnyAction;
33
+ }
34
+
35
+ interface ApprovalPayload {
36
+ approved: boolean;
37
+ reason?: string;
38
+ }
39
+
40
+ const approvalSchema: Schema<ApprovalPayload> = {
41
+ "~standard": {
42
+ version: 1,
43
+ vendor: "tesser",
44
+ validate(value) {
45
+ if (typeof value === "object" && value !== null && typeof (value as { approved?: unknown }).approved === "boolean") {
46
+ const v = value as { approved: boolean; reason?: unknown };
47
+ return {
48
+ value: {
49
+ approved: v.approved,
50
+ ...(typeof v.reason === "string" ? { reason: v.reason } : {}),
51
+ },
52
+ };
53
+ }
54
+ return { issues: [{ message: "expected { approved: boolean }" }] };
55
+ },
56
+ },
57
+ };
58
+
59
+ export function buildOperators(
60
+ def: AutomationDef<any, any, any, any, any, any, any>,
61
+ ctx: Ctx<any, any, any>,
62
+ callModel: ModelCaller,
63
+ ): Operators<any> {
64
+ const usage = { tokens: 0, outputTokens: 0 };
65
+ const out: Record<string, (input: unknown) => Promise<unknown>> = {};
66
+ for (const [operatorKey, op] of Object.entries(def.operators ?? {})) {
67
+ out[operatorKey] = (input: unknown) =>
68
+ executeOperator({ def, ctx, operatorKey, op: op as OperatorDef, input, usage, callModel });
69
+ }
70
+ return Object.freeze(out);
71
+ }
72
+
73
+ async function executeOperator(opts: {
74
+ def: AutomationDef<any, any, any, any, any, any, any>;
75
+ ctx: Ctx<any, any, any>;
76
+ operatorKey: string;
77
+ op: OperatorDef;
78
+ input: unknown;
79
+ usage: { tokens: number; outputTokens: number };
80
+ callModel: ModelCaller;
81
+ }): Promise<unknown> {
82
+ const { def, ctx, operatorKey, op } = opts;
83
+ const modelKey = op.model;
84
+ const model = def.models?.[modelKey];
85
+ if (!model) throw new TerminalError(`operator.${operatorKey}: unknown model "${modelKey}"`);
86
+
87
+ const validatedInput = await validateSchema(op.input, opts.input, `operator.${operatorKey} input`);
88
+ const serialInput = toSerializable(validatedInput, `operator.${operatorKey} input`);
89
+ const tools = await resolveTools(def, op, operatorKey);
90
+ const messages: ModelMessage[] = [{ role: "user", content: JSON.stringify(serialInput) }];
91
+ const outputJsonSchema = await schemaJson(op.output);
92
+ let tainted = true; // trigger payload / Operator input is coarse-tainted in v1.
93
+
94
+ for (let turn = 1; turn <= op.maxTurns; turn++) {
95
+ assertBudget(def, model, opts.usage, operatorKey);
96
+ const request: NormalizedModelRequest = {
97
+ operatorKey,
98
+ modelKey,
99
+ alias: model.alias,
100
+ instructions: op.instructions,
101
+ input: serialInput,
102
+ messages,
103
+ tools: tools.map((t) => t.descriptor),
104
+ ...(model.settings !== undefined ? { settings: model.settings } : {}),
105
+ ...(outputJsonSchema !== undefined ? { outputJsonSchema } : {}),
106
+ };
107
+ const response = (await ctx.step(`operator.${operatorKey}.model.${turn}`, async () =>
108
+ toSerializable(
109
+ await opts.callModel({ automationId: def.id, operatorKey, modelKey, model, request }),
110
+ `operator.${operatorKey} model response`,
111
+ ),
112
+ )) as unknown as NormalizedModelResponse;
113
+ validateModelResponse(response, operatorKey, turn);
114
+ addUsage(opts.usage, response);
115
+ assertBudget(def, model, opts.usage, operatorKey);
116
+
117
+ const toolCalls = response.toolCalls ?? [];
118
+ if (toolCalls.length > 0) {
119
+ messages.push({ role: "assistant", content: response.content ?? `requested ${toolCalls.length} tool call(s)` });
120
+ let index = 0;
121
+ for (const call of toolCalls) {
122
+ index++;
123
+ const tool = toolForCall(tools, call);
124
+ if (!tool) {
125
+ throw new TerminalError(`operator.${operatorKey}: model requested undeclared tool "${call.name}"`);
126
+ }
127
+ if (tool.action.safety === "write") {
128
+ const approval = await ctx.waitForSignal(`operator.${operatorKey}.approval`, {
129
+ schema: approvalSchema,
130
+ timeout: "1h",
131
+ });
132
+ if (approval === null) {
133
+ throw new TerminalError(`operator.${operatorKey}: approval timed out for write tool ${tool.descriptor.path}`);
134
+ }
135
+ if (!approval.approved) {
136
+ throw new TerminalError(`operator.${operatorKey}: approval denied for write tool ${tool.descriptor.path}`);
137
+ }
138
+ }
139
+ const toolResult = await ctx.step(`operator.${operatorKey}.tool.${turn}.${index}.${tool.descriptor.path}`, async () => {
140
+ const fn = actionFunction(ctx.connections as Record<string, unknown>, tool.connectionKey, tool.actionPath);
141
+ return toSerializable(await fn(call.input), `operator.${operatorKey} tool ${tool.descriptor.path} output`);
142
+ });
143
+ if (tool.action.safety === "read") tainted = true;
144
+ messages.push({
145
+ role: "tool",
146
+ toolCallId: call.id,
147
+ content: minimizeToolOutput(toolResult, { tainted }),
148
+ });
149
+ }
150
+ continue;
151
+ }
152
+
153
+ const rawOutput = response.output !== undefined ? response.output : parseJson(response.content, operatorKey, turn);
154
+ return validateSchema(op.output, rawOutput, `operator.${operatorKey} output`);
155
+ }
156
+ throw new TerminalError(`operator.${operatorKey}: exceeded maxTurns (${op.maxTurns})`);
157
+ }
158
+
159
+ async function resolveTools(
160
+ def: AutomationDef<any, any, any, any, any, any, any>,
161
+ op: OperatorDef,
162
+ operatorKey: string,
163
+ ): Promise<ToolRuntime[]> {
164
+ const out: ToolRuntime[] = [];
165
+ const connections = (def.connections ?? {}) as Record<string, ConnectorInstance<ActionsTree, TriggersDecl>>;
166
+ for (const toolPath of op.tools) {
167
+ const [connectionKey, ...actionPath] = toolPath.split(".");
168
+ const connector = connections[connectionKey!];
169
+ if (!connector) throw new TerminalError(`operator.${operatorKey}: undeclared tool connection "${connectionKey}"`);
170
+ const action = actionAtPath(connector, actionPath);
171
+ if (!action) throw new TerminalError(`operator.${operatorKey}: tool "${toolPath}" is not a declared Action`);
172
+ const inputSchema = await schemaJson(action.input as Schema<unknown>);
173
+ out.push({
174
+ connectionKey: connectionKey!,
175
+ actionPath,
176
+ action,
177
+ descriptor: {
178
+ name: toolName(toolPath),
179
+ path: toolPath,
180
+ ...(action.describe !== undefined ? { description: action.describe } : {}),
181
+ ...(inputSchema !== undefined ? { inputSchema } : {}),
182
+ },
183
+ });
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function toolForCall(tools: ToolRuntime[], call: ModelToolCall): ToolRuntime | undefined {
189
+ return tools.find((t) => call.name === t.descriptor.name || call.name === t.descriptor.path);
190
+ }
191
+
192
+ export function toolName(path: string): string {
193
+ return path.replace(/[^A-Za-z0-9_-]/g, "__");
194
+ }
195
+
196
+ function actionFunction(
197
+ connections: Record<string, unknown>,
198
+ connectionKey: string,
199
+ actionPath: string[],
200
+ ): (input: unknown) => Promise<unknown> {
201
+ let node = connections[connectionKey];
202
+ for (const seg of actionPath) node = (node as Record<string, unknown> | undefined)?.[seg];
203
+ if (typeof node !== "function") {
204
+ throw new TerminalError(`operator tool ${connectionKey}.${actionPath.join(".")} is not callable`);
205
+ }
206
+ return node as (input: unknown) => Promise<unknown>;
207
+ }
208
+
209
+ function validateModelResponse(response: NormalizedModelResponse, operatorKey: string, turn: number): void {
210
+ if (typeof response !== "object" || response === null) {
211
+ throw new TerminalError(`operator.${operatorKey} turn ${turn}: model adapter returned a non-object response`);
212
+ }
213
+ if (!response.usage || typeof response.usage.inputTokens !== "number" || typeof response.usage.outputTokens !== "number") {
214
+ throw new TerminalError(`operator.${operatorKey} turn ${turn}: model response missing usage tokens`);
215
+ }
216
+ }
217
+
218
+ function addUsage(usage: { tokens: number; outputTokens: number }, response: NormalizedModelResponse): void {
219
+ usage.tokens += response.usage.inputTokens + response.usage.outputTokens + (response.usage.reasoningTokens ?? 0);
220
+ usage.outputTokens += response.usage.outputTokens;
221
+ }
222
+
223
+ function assertBudget(
224
+ def: AutomationDef<any, any, any, any, any, any, any>,
225
+ model: ModelDef,
226
+ usage: { tokens: number; outputTokens: number },
227
+ operatorKey: string,
228
+ ): void {
229
+ const budget = def.budget?.models;
230
+ if (!budget) throw new TerminalError(`operator.${operatorKey}: budget.models is required`);
231
+ if (usage.tokens >= budget.tokens) {
232
+ throw new TerminalError(`operator.${operatorKey}: model token budget exceeded (${usage.tokens}/${budget.tokens})`);
233
+ }
234
+ if (usage.outputTokens >= budget.outputTokens) {
235
+ throw new TerminalError(
236
+ `operator.${operatorKey}: model output-token budget exceeded (${usage.outputTokens}/${budget.outputTokens})`,
237
+ );
238
+ }
239
+ const maxOut = model.settings?.maxOutputTokens;
240
+ if (maxOut !== undefined && maxOut > budget.outputTokens - usage.outputTokens) {
241
+ throw new TerminalError(`operator.${operatorKey}: model maxOutputTokens exceeds remaining output-token budget`);
242
+ }
243
+ }
244
+
245
+ function parseJson(content: string | undefined, operatorKey: string, turn: number): unknown {
246
+ if (!content) throw new TerminalError(`operator.${operatorKey} turn ${turn}: model returned no output`);
247
+ const trimmed = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
248
+ try {
249
+ return JSON.parse(trimmed);
250
+ } catch (cause) {
251
+ throw new TerminalError(`operator.${operatorKey} turn ${turn}: model output was not JSON`, { cause });
252
+ }
253
+ }
254
+
255
+ function toSerializable(value: unknown, what: string): Serializable {
256
+ try {
257
+ return decodeJournal(encodeJournal(value)) as Serializable;
258
+ } catch (cause) {
259
+ throw new TerminalError(`${what} is not serializable`, { cause });
260
+ }
261
+ }
262
+
263
+ function minimizeToolOutput(value: Serializable, opts: { tainted: boolean }): string {
264
+ const raw = JSON.stringify({ tainted: opts.tainted, value: encodeJournal(value) });
265
+ return raw.length <= 4000 ? raw : raw.slice(0, 3997) + "...";
266
+ }
267
+
268
+ async function schemaJson(schema: Schema<unknown>): Promise<Serializable | undefined> {
269
+ const std = (schema as { "~standard"?: { vendor?: string } })["~standard"];
270
+ try {
271
+ let json: unknown;
272
+ const direct = schema as unknown as { toJSONSchema?: () => unknown };
273
+ if (typeof direct.toJSONSchema === "function") json = direct.toJSONSchema();
274
+ else if (std?.vendor === "zod") {
275
+ const zod = (await import("zod")) as unknown as {
276
+ toJSONSchema?: (s: unknown, opts?: unknown) => unknown;
277
+ z?: { toJSONSchema?: (s: unknown, opts?: unknown) => unknown };
278
+ };
279
+ const convert = zod.toJSONSchema ?? zod.z?.toJSONSchema;
280
+ if (convert) json = convert(schema, { unrepresentable: "any" });
281
+ }
282
+ if (json === undefined) return undefined;
283
+ return decodeJournal(encodeJournal(json as JsonValue)) as Serializable;
284
+ } catch {
285
+ return undefined;
286
+ }
287
+ }