@artemiskit/core 0.6.0 → 0.6.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/CHANGELOG.md +15 -0
- package/README.md +12 -0
- package/dist/adapters/types.d.ts +7 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/agent-workflow/catalog.d.ts +2 -1
- package/dist/agent-workflow/catalog.d.ts.map +1 -1
- package/dist/agent-workflow/environment.d.ts +47 -0
- package/dist/agent-workflow/environment.d.ts.map +1 -0
- package/dist/agent-workflow/index.d.ts +4 -1
- package/dist/agent-workflow/index.d.ts.map +1 -1
- package/dist/agent-workflow/parser.d.ts +1 -1
- package/dist/agent-workflow/parser.d.ts.map +1 -1
- package/dist/agent-workflow/sandbox-fixtures/qualify.d.ts +2 -0
- package/dist/agent-workflow/sandbox-fixtures/qualify.d.ts.map +1 -0
- package/dist/agent-workflow/sandbox.d.ts +12 -0
- package/dist/agent-workflow/sandbox.d.ts.map +1 -0
- package/dist/agent-workflow/schema.d.ts +117 -7
- package/dist/agent-workflow/schema.d.ts.map +1 -1
- package/dist/agent-workflow/session.d.ts +113 -0
- package/dist/agent-workflow/session.d.ts.map +1 -0
- package/dist/agent-workflow/target.d.ts +20 -7
- package/dist/agent-workflow/target.d.ts.map +1 -1
- package/dist/index.js +1366 -21
- package/package.json +1 -1
- package/src/adapters/types.ts +7 -0
- package/src/agent-workflow/catalog.ts +4 -3
- package/src/agent-workflow/environment.ts +207 -0
- package/src/agent-workflow/index.ts +5 -1
- package/src/agent-workflow/parser.ts +1 -1
- package/src/agent-workflow/sandbox-fixtures/qualify.ts +305 -0
- package/src/agent-workflow/sandbox.test.ts +117 -0
- package/src/agent-workflow/sandbox.ts +438 -0
- package/src/agent-workflow/schema.ts +18 -2
- package/src/agent-workflow/session.test.ts +629 -0
- package/src/agent-workflow/session.ts +1119 -0
- package/src/agent-workflow/target.test.ts +5 -1
- package/src/agent-workflow/target.ts +82 -17
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import Ajv from 'ajv';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { TokenUsage } from '../adapters/types';
|
|
5
|
+
import { getWorkflowTool } from './catalog';
|
|
6
|
+
import {
|
|
7
|
+
type WorkflowEnvironment,
|
|
8
|
+
type WorkflowEnvironmentFactory,
|
|
9
|
+
WorkflowEnvironmentInitializationError,
|
|
10
|
+
type WorkflowState,
|
|
11
|
+
createSimulatedWorkflowEnvironment,
|
|
12
|
+
isWorkflowState,
|
|
13
|
+
resolveWorkflowInitialState,
|
|
14
|
+
workflowPathAllowed,
|
|
15
|
+
workflowToolPermitted,
|
|
16
|
+
} from './environment';
|
|
17
|
+
import { createDockerWorkflowEnvironment } from './sandbox';
|
|
18
|
+
import { type AgentWorkflow, AgentWorkflowSchema, isWorkflowJson } from './schema';
|
|
19
|
+
import type { AgentTarget, AgentTurnRequest, AgentTurnResult } from './target';
|
|
20
|
+
import { agentTurnRequestSchema, validWorkflowTranscript } from './target';
|
|
21
|
+
|
|
22
|
+
export type AgentWorkflowExecution =
|
|
23
|
+
| 'completed'
|
|
24
|
+
| 'unsupported'
|
|
25
|
+
| 'invalid'
|
|
26
|
+
| 'failed'
|
|
27
|
+
| 'cancelled'
|
|
28
|
+
| 'timeout'
|
|
29
|
+
| 'budget_exceeded';
|
|
30
|
+
export type AgentWorkflowReason =
|
|
31
|
+
| 'finished'
|
|
32
|
+
| 'invalid_workflow'
|
|
33
|
+
| 'invalid_options'
|
|
34
|
+
| 'invalid_fixture'
|
|
35
|
+
| 'target_unavailable'
|
|
36
|
+
| 'invalid_response'
|
|
37
|
+
| 'target_error'
|
|
38
|
+
| 'tool_use_unsupported'
|
|
39
|
+
| 'preflight_failed'
|
|
40
|
+
| 'environment_unavailable'
|
|
41
|
+
| 'invalid_environment'
|
|
42
|
+
| 'tool_failed'
|
|
43
|
+
| 'policy_denied'
|
|
44
|
+
| 'cancelled'
|
|
45
|
+
| 'deadline'
|
|
46
|
+
| 'max_actions'
|
|
47
|
+
| 'max_model_requests'
|
|
48
|
+
| 'max_tool_calls'
|
|
49
|
+
| 'max_tokens'
|
|
50
|
+
| 'usage_unavailable'
|
|
51
|
+
| 'transcript_limit';
|
|
52
|
+
export interface AgentWorkflowEvent {
|
|
53
|
+
sequence: number;
|
|
54
|
+
elapsedMs: number;
|
|
55
|
+
type:
|
|
56
|
+
| 'started'
|
|
57
|
+
| 'model_requested'
|
|
58
|
+
| 'model_completed'
|
|
59
|
+
| 'tool_requested'
|
|
60
|
+
| 'tool_completed'
|
|
61
|
+
| 'preflight_completed'
|
|
62
|
+
| 'finished';
|
|
63
|
+
phase: 'execution' | 'preflight';
|
|
64
|
+
operationId?: string;
|
|
65
|
+
requestedCallIdHash?: string;
|
|
66
|
+
tool?: string;
|
|
67
|
+
status?: 'completed' | 'denied' | 'invalid' | 'failed';
|
|
68
|
+
}
|
|
69
|
+
export interface AgentWorkflowRecord {
|
|
70
|
+
schemaVersion: '1';
|
|
71
|
+
engine: 'native';
|
|
72
|
+
execution: AgentWorkflowExecution;
|
|
73
|
+
reason: AgentWorkflowReason;
|
|
74
|
+
policy: 'passed' | 'denied';
|
|
75
|
+
configuration?: {
|
|
76
|
+
sha256: string;
|
|
77
|
+
provider: { sha256: string; display?: string };
|
|
78
|
+
model: { sha256: string; display?: string };
|
|
79
|
+
generation: { maxTokens: number; temperature: number };
|
|
80
|
+
limits: AgentWorkflow['environment']['policy']['budgets'];
|
|
81
|
+
};
|
|
82
|
+
taskVerification: 'unavailable';
|
|
83
|
+
environment: 'simulated' | 'sandbox' | 'unknown';
|
|
84
|
+
capability: {
|
|
85
|
+
advertised: boolean | null;
|
|
86
|
+
transportCancellation: boolean;
|
|
87
|
+
preflight: 'not_requested' | 'passed' | 'failed';
|
|
88
|
+
observedModelHash?: string;
|
|
89
|
+
observedModel?: { sha256: string; display?: string };
|
|
90
|
+
};
|
|
91
|
+
usage: {
|
|
92
|
+
status: 'reported' | 'partial' | 'unavailable';
|
|
93
|
+
reported: TokenUsage;
|
|
94
|
+
missingRequests: number;
|
|
95
|
+
inFlightUnknown: boolean;
|
|
96
|
+
preflight: TokenUsage;
|
|
97
|
+
};
|
|
98
|
+
budgets: {
|
|
99
|
+
actions: number;
|
|
100
|
+
modelRequests: number;
|
|
101
|
+
toolCalls: number;
|
|
102
|
+
modelRequestAccounting: 'target_invocations';
|
|
103
|
+
transportAttempts: 'unavailable';
|
|
104
|
+
tokenOvershoot: number;
|
|
105
|
+
elapsedMs: number;
|
|
106
|
+
};
|
|
107
|
+
cleanup: {
|
|
108
|
+
status: 'completed' | 'unresolved';
|
|
109
|
+
artifacts: 'discarded' | 'retained' | 'unknown';
|
|
110
|
+
pendingOperations: number;
|
|
111
|
+
};
|
|
112
|
+
artifacts: {
|
|
113
|
+
state: 'available' | 'unavailable';
|
|
114
|
+
stateSha256?: string;
|
|
115
|
+
files?: { pathSha256: string; contentSha256: string; bytes: number }[];
|
|
116
|
+
omittedFiles?: number;
|
|
117
|
+
};
|
|
118
|
+
events: AgentWorkflowEvent[];
|
|
119
|
+
droppedEvents: number;
|
|
120
|
+
}
|
|
121
|
+
export interface AgentWorkflowResult {
|
|
122
|
+
/** Metadata only. Safe default persistence boundary, never contains model text or fixture content. */
|
|
123
|
+
record: AgentWorkflowRecord;
|
|
124
|
+
/** Sensitive working data; persist only with an explicit application policy. */
|
|
125
|
+
state: WorkflowState | null;
|
|
126
|
+
transcript: AgentTurnRequest['messages'];
|
|
127
|
+
}
|
|
128
|
+
export interface AgentWorkflowSessionOptions {
|
|
129
|
+
workflow: AgentWorkflow;
|
|
130
|
+
target: AgentTarget;
|
|
131
|
+
fixtureRoot?: string;
|
|
132
|
+
environmentFactory?: WorkflowEnvironmentFactory;
|
|
133
|
+
preflight?: boolean;
|
|
134
|
+
preflightOnly?: boolean;
|
|
135
|
+
signal?: AbortSignal;
|
|
136
|
+
/** Total bounded drain/snapshot/close period: 1000 ms simulated, 6000 ms sandbox by default. */
|
|
137
|
+
cleanupTimeoutMs?: number;
|
|
138
|
+
onEvent?: (event: AgentWorkflowEvent) => void;
|
|
139
|
+
}
|
|
140
|
+
export interface AgentWorkflowSession {
|
|
141
|
+
readonly state: 'idle' | 'running' | 'cancelling' | 'completed';
|
|
142
|
+
run(): Promise<AgentWorkflowResult>;
|
|
143
|
+
cancel(): void;
|
|
144
|
+
events(): AsyncIterable<AgentWorkflowEvent>;
|
|
145
|
+
}
|
|
146
|
+
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
147
|
+
function identity(value: string) {
|
|
148
|
+
return {
|
|
149
|
+
sha256: hash(value),
|
|
150
|
+
...(/^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/.test(value) &&
|
|
151
|
+
!/(?:secret|token|password|credential|api.?key|^sk-|^npm_)/i.test(value)
|
|
152
|
+
? { display: value }
|
|
153
|
+
: {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const zeroUsage = (): TokenUsage => ({ prompt: 0, completion: 0, total: 0 });
|
|
157
|
+
const usageSchema = z
|
|
158
|
+
.object({
|
|
159
|
+
prompt: z.number().int().nonnegative().safe(),
|
|
160
|
+
completion: z.number().int().nonnegative().safe(),
|
|
161
|
+
total: z.number().int().nonnegative().safe(),
|
|
162
|
+
})
|
|
163
|
+
.strict();
|
|
164
|
+
const callSchema = z
|
|
165
|
+
.object({
|
|
166
|
+
id: z.string().min(1).max(256),
|
|
167
|
+
type: z.literal('function'),
|
|
168
|
+
function: z
|
|
169
|
+
.object({
|
|
170
|
+
name: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$/),
|
|
171
|
+
arguments: z.string().max(1_000_000),
|
|
172
|
+
})
|
|
173
|
+
.strict(),
|
|
174
|
+
})
|
|
175
|
+
.strict();
|
|
176
|
+
const completedSchema = z
|
|
177
|
+
.object({
|
|
178
|
+
status: z.literal('completed'),
|
|
179
|
+
id: z.string().min(1).max(256),
|
|
180
|
+
model: z.string().min(1).max(256),
|
|
181
|
+
message: z
|
|
182
|
+
.object({
|
|
183
|
+
role: z.literal('assistant'),
|
|
184
|
+
content: z.string().max(1_000_000),
|
|
185
|
+
tool_calls: z.array(callSchema).max(100).optional(),
|
|
186
|
+
})
|
|
187
|
+
.strict(),
|
|
188
|
+
tokens: usageSchema,
|
|
189
|
+
usageAvailable: z.boolean().optional(),
|
|
190
|
+
latencyMs: z.number().finite().nonnegative(),
|
|
191
|
+
finishReason: z.enum(['stop', 'length', 'tool_calls', 'content_filter']).optional(),
|
|
192
|
+
})
|
|
193
|
+
.strict();
|
|
194
|
+
const failureSchema = z
|
|
195
|
+
.object({
|
|
196
|
+
status: z.enum(['unsupported', 'invalid', 'error']),
|
|
197
|
+
tokens: usageSchema.optional(),
|
|
198
|
+
usageAvailable: z.boolean().optional(),
|
|
199
|
+
rejectedCall: z
|
|
200
|
+
.object({
|
|
201
|
+
requestedCallIdHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
202
|
+
tool: z.string(),
|
|
203
|
+
reason: z.enum(['undeclared_tool', 'invalid_arguments', 'duplicate_id']),
|
|
204
|
+
})
|
|
205
|
+
.strict()
|
|
206
|
+
.optional(),
|
|
207
|
+
code: z.enum([
|
|
208
|
+
'invalid_request',
|
|
209
|
+
'tool_use_unsupported',
|
|
210
|
+
'invalid_response',
|
|
211
|
+
'target_error',
|
|
212
|
+
'timeout',
|
|
213
|
+
'aborted',
|
|
214
|
+
]),
|
|
215
|
+
})
|
|
216
|
+
.strict();
|
|
217
|
+
const capabilitySchema = z
|
|
218
|
+
.object({
|
|
219
|
+
status: z.literal('available'),
|
|
220
|
+
toolUse: z.boolean(),
|
|
221
|
+
transportCancellation: z.boolean(),
|
|
222
|
+
})
|
|
223
|
+
.strict();
|
|
224
|
+
const cleanupSchema = z
|
|
225
|
+
.object({
|
|
226
|
+
status: z.enum(['completed', 'unresolved']),
|
|
227
|
+
artifacts: z.enum(['discarded', 'retained', 'unknown']),
|
|
228
|
+
})
|
|
229
|
+
.strict();
|
|
230
|
+
const failureCodes = new Set([
|
|
231
|
+
'undeclared_tool',
|
|
232
|
+
'permission_denied',
|
|
233
|
+
'invalid_input',
|
|
234
|
+
'invalid_state',
|
|
235
|
+
'not_found',
|
|
236
|
+
'output_limit',
|
|
237
|
+
'invalid_policy',
|
|
238
|
+
'tool_error',
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
/** Reject executable getters and oversized custom-target records before reading schema fields. */
|
|
242
|
+
function safeBoundary(value: unknown): boolean {
|
|
243
|
+
let nodes = 0;
|
|
244
|
+
let bytes = 0;
|
|
245
|
+
const parents = new Set<object>();
|
|
246
|
+
function check(item: unknown, depth: number): boolean {
|
|
247
|
+
if (++nodes > 10_000 || depth > 16) return false;
|
|
248
|
+
if (item === undefined || item === null || typeof item === 'boolean') return true;
|
|
249
|
+
if (typeof item === 'number') return Number.isFinite(item);
|
|
250
|
+
if (typeof item === 'string') {
|
|
251
|
+
bytes += Buffer.byteLength(item);
|
|
252
|
+
return bytes <= 1_048_576;
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
typeof item !== 'object' ||
|
|
256
|
+
parents.has(item) ||
|
|
257
|
+
(!Array.isArray(item) &&
|
|
258
|
+
Object.getPrototypeOf(item) !== Object.prototype &&
|
|
259
|
+
Object.getPrototypeOf(item) !== null)
|
|
260
|
+
)
|
|
261
|
+
return false;
|
|
262
|
+
if (Object.getOwnPropertySymbols(item).length || (Array.isArray(item) && item.length > 10_000))
|
|
263
|
+
return false;
|
|
264
|
+
parents.add(item);
|
|
265
|
+
for (const key of Object.getOwnPropertyNames(item)) {
|
|
266
|
+
if (Array.isArray(item) && key === 'length') continue;
|
|
267
|
+
bytes += Buffer.byteLength(key);
|
|
268
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
|
269
|
+
if (
|
|
270
|
+
bytes > 1_048_576 ||
|
|
271
|
+
['__proto__', 'constructor', 'prototype'].includes(key) ||
|
|
272
|
+
!descriptor ||
|
|
273
|
+
!('value' in descriptor) ||
|
|
274
|
+
!descriptor.enumerable ||
|
|
275
|
+
!check(descriptor.value, depth + 1)
|
|
276
|
+
)
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
parents.delete(item);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return check(value, 0);
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
class Stop extends Error {
|
|
289
|
+
constructor(
|
|
290
|
+
readonly execution: AgentWorkflowExecution,
|
|
291
|
+
readonly reason: AgentWorkflowReason
|
|
292
|
+
) {
|
|
293
|
+
super(reason);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Native host-owned loop. No adapter or model may change authority or replenish run budgets. */
|
|
298
|
+
export function createAgentWorkflowSession(
|
|
299
|
+
options: AgentWorkflowSessionOptions
|
|
300
|
+
): AgentWorkflowSession {
|
|
301
|
+
const controller = new AbortController();
|
|
302
|
+
let sessionState: AgentWorkflowSession['state'] = 'idle';
|
|
303
|
+
let promise: Promise<AgentWorkflowResult> | undefined;
|
|
304
|
+
let workflow: AgentWorkflow | undefined;
|
|
305
|
+
try {
|
|
306
|
+
workflow = AgentWorkflowSchema.parse(options.workflow);
|
|
307
|
+
} catch {
|
|
308
|
+
/* Safe error returned by run. */
|
|
309
|
+
}
|
|
310
|
+
const target = options.target;
|
|
311
|
+
const retained: AgentWorkflowEvent[] = [];
|
|
312
|
+
const listeners = new Set<() => void>();
|
|
313
|
+
let finished = false;
|
|
314
|
+
let started = 0;
|
|
315
|
+
let eventSequence = 0;
|
|
316
|
+
const record: AgentWorkflowRecord = {
|
|
317
|
+
schemaVersion: '1',
|
|
318
|
+
engine: 'native',
|
|
319
|
+
execution: 'invalid',
|
|
320
|
+
reason: 'invalid_workflow',
|
|
321
|
+
policy: 'passed',
|
|
322
|
+
...(workflow
|
|
323
|
+
? {
|
|
324
|
+
configuration: {
|
|
325
|
+
sha256: hash(JSON.stringify(workflow)),
|
|
326
|
+
provider: identity(workflow.target.provider),
|
|
327
|
+
model: identity(workflow.target.model),
|
|
328
|
+
generation: {
|
|
329
|
+
maxTokens: workflow.target.generation?.max_tokens ?? 1024,
|
|
330
|
+
temperature: workflow.target.generation?.temperature ?? 0,
|
|
331
|
+
},
|
|
332
|
+
limits: structuredClone(workflow.environment.policy.budgets),
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
: {}),
|
|
336
|
+
taskVerification: 'unavailable',
|
|
337
|
+
environment: workflow?.environment.type ?? 'unknown',
|
|
338
|
+
capability: {
|
|
339
|
+
advertised: null,
|
|
340
|
+
transportCancellation: false,
|
|
341
|
+
preflight: options.preflight || options.preflightOnly ? 'failed' : 'not_requested',
|
|
342
|
+
},
|
|
343
|
+
usage: {
|
|
344
|
+
status: 'unavailable',
|
|
345
|
+
reported: zeroUsage(),
|
|
346
|
+
missingRequests: 0,
|
|
347
|
+
inFlightUnknown: false,
|
|
348
|
+
preflight: zeroUsage(),
|
|
349
|
+
},
|
|
350
|
+
budgets: {
|
|
351
|
+
actions: 0,
|
|
352
|
+
modelRequests: 0,
|
|
353
|
+
toolCalls: 0,
|
|
354
|
+
modelRequestAccounting: 'target_invocations',
|
|
355
|
+
transportAttempts: 'unavailable',
|
|
356
|
+
tokenOvershoot: 0,
|
|
357
|
+
elapsedMs: 0,
|
|
358
|
+
},
|
|
359
|
+
cleanup: { status: 'completed', artifacts: 'discarded', pendingOperations: 0 },
|
|
360
|
+
artifacts: { state: 'unavailable' },
|
|
361
|
+
events: retained,
|
|
362
|
+
droppedEvents: 0,
|
|
363
|
+
};
|
|
364
|
+
const pending = new Set<Promise<unknown>>();
|
|
365
|
+
const modelPending = new Set<Promise<unknown>>();
|
|
366
|
+
let transcript: AgentTurnRequest['messages'] = [];
|
|
367
|
+
let state: WorkflowState | null = null;
|
|
368
|
+
let environment: WorkflowEnvironment | undefined;
|
|
369
|
+
let deadlineExpired = false;
|
|
370
|
+
let phase: AgentWorkflowEvent['phase'] = 'execution';
|
|
371
|
+
let measuredRequests = 0;
|
|
372
|
+
const ids = new Set<string>();
|
|
373
|
+
const emit = (event: Omit<AgentWorkflowEvent, 'sequence' | 'elapsedMs' | 'phase'>) => {
|
|
374
|
+
const value = {
|
|
375
|
+
...event,
|
|
376
|
+
sequence: ++eventSequence,
|
|
377
|
+
elapsedMs: Math.max(0, Date.now() - started),
|
|
378
|
+
phase,
|
|
379
|
+
};
|
|
380
|
+
if (retained.length < 255 || value.type === 'finished') retained.push(value);
|
|
381
|
+
else record.droppedEvents++;
|
|
382
|
+
try {
|
|
383
|
+
options.onEvent?.(structuredClone(value));
|
|
384
|
+
} catch {
|
|
385
|
+
/* Observers cannot alter execution. */
|
|
386
|
+
}
|
|
387
|
+
for (const wake of listeners) wake();
|
|
388
|
+
};
|
|
389
|
+
const abort = () => {
|
|
390
|
+
if (!finished) {
|
|
391
|
+
controller.abort();
|
|
392
|
+
if (sessionState === 'running') sessionState = 'cancelling';
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
396
|
+
if (options.signal?.aborted) abort();
|
|
397
|
+
function active() {
|
|
398
|
+
if (controller.signal.aborted)
|
|
399
|
+
throw new Stop(
|
|
400
|
+
deadlineExpired ? 'timeout' : 'cancelled',
|
|
401
|
+
deadlineExpired ? 'deadline' : 'cancelled'
|
|
402
|
+
);
|
|
403
|
+
if (workflow && Date.now() - started >= workflow.environment.policy.budgets.timeout_ms) {
|
|
404
|
+
deadlineExpired = true;
|
|
405
|
+
controller.abort();
|
|
406
|
+
throw new Stop('timeout', 'deadline');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function owned<T>(
|
|
410
|
+
operation: () => Promise<T>,
|
|
411
|
+
signal: AbortSignal,
|
|
412
|
+
isModel = false
|
|
413
|
+
): Promise<T> {
|
|
414
|
+
if (signal.aborted)
|
|
415
|
+
throw new Stop(
|
|
416
|
+
deadlineExpired ? 'timeout' : 'cancelled',
|
|
417
|
+
deadlineExpired ? 'deadline' : 'cancelled'
|
|
418
|
+
);
|
|
419
|
+
const work = Promise.resolve().then(() => {
|
|
420
|
+
if (signal.aborted) throw new Stop('cancelled', 'cancelled');
|
|
421
|
+
return operation();
|
|
422
|
+
});
|
|
423
|
+
pending.add(work);
|
|
424
|
+
if (isModel) modelPending.add(work);
|
|
425
|
+
void work.then(
|
|
426
|
+
() => {
|
|
427
|
+
pending.delete(work);
|
|
428
|
+
modelPending.delete(work);
|
|
429
|
+
},
|
|
430
|
+
() => {
|
|
431
|
+
pending.delete(work);
|
|
432
|
+
modelPending.delete(work);
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
return new Promise<T>((resolve, reject) => {
|
|
436
|
+
const onAbort = () => {
|
|
437
|
+
signal.removeEventListener('abort', onAbort);
|
|
438
|
+
reject(
|
|
439
|
+
new Stop(
|
|
440
|
+
deadlineExpired ? 'timeout' : 'cancelled',
|
|
441
|
+
deadlineExpired ? 'deadline' : 'cancelled'
|
|
442
|
+
)
|
|
443
|
+
);
|
|
444
|
+
};
|
|
445
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
446
|
+
void work.then(
|
|
447
|
+
(value) => {
|
|
448
|
+
signal.removeEventListener('abort', onAbort);
|
|
449
|
+
resolve(value);
|
|
450
|
+
},
|
|
451
|
+
(error) => {
|
|
452
|
+
signal.removeEventListener('abort', onAbort);
|
|
453
|
+
reject(error);
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
function admit(kind: 'model' | 'tool') {
|
|
459
|
+
active();
|
|
460
|
+
if (!workflow) throw new Stop('invalid', 'invalid_workflow');
|
|
461
|
+
const budget = workflow.environment.policy.budgets;
|
|
462
|
+
if (record.budgets.actions >= budget.max_actions)
|
|
463
|
+
throw new Stop('budget_exceeded', 'max_actions');
|
|
464
|
+
if (
|
|
465
|
+
kind === 'model' &&
|
|
466
|
+
record.budgets.modelRequests >= (budget.max_model_requests ?? budget.max_actions)
|
|
467
|
+
)
|
|
468
|
+
throw new Stop('budget_exceeded', 'max_model_requests');
|
|
469
|
+
if (
|
|
470
|
+
kind === 'tool' &&
|
|
471
|
+
record.budgets.toolCalls >= (budget.max_tool_calls ?? budget.max_actions)
|
|
472
|
+
)
|
|
473
|
+
throw new Stop('budget_exceeded', 'max_tool_calls');
|
|
474
|
+
if (budget.max_tokens !== undefined) {
|
|
475
|
+
if (record.usage.missingRequests) throw new Stop('budget_exceeded', 'usage_unavailable');
|
|
476
|
+
if (record.usage.reported.total >= budget.max_tokens)
|
|
477
|
+
throw new Stop('budget_exceeded', 'max_tokens');
|
|
478
|
+
}
|
|
479
|
+
record.budgets.actions++;
|
|
480
|
+
if (kind === 'model') record.budgets.modelRequests++;
|
|
481
|
+
else record.budgets.toolCalls++;
|
|
482
|
+
}
|
|
483
|
+
const ajv = new Ajv({
|
|
484
|
+
strict: true,
|
|
485
|
+
allErrors: false,
|
|
486
|
+
coerceTypes: false,
|
|
487
|
+
useDefaults: false,
|
|
488
|
+
removeAdditional: false,
|
|
489
|
+
});
|
|
490
|
+
async function model(
|
|
491
|
+
messages: AgentTurnRequest['messages'],
|
|
492
|
+
tools: AgentTurnRequest['tools']
|
|
493
|
+
): Promise<Extract<AgentTurnResult, { status: 'completed' }>> {
|
|
494
|
+
if (!workflow) throw new Stop('invalid', 'invalid_workflow');
|
|
495
|
+
const budget = workflow.environment.policy.budgets;
|
|
496
|
+
const remainingTokens =
|
|
497
|
+
budget.max_tokens === undefined
|
|
498
|
+
? 1_000_000
|
|
499
|
+
: Math.max(1, budget.max_tokens - record.usage.reported.total);
|
|
500
|
+
const request: AgentTurnRequest = {
|
|
501
|
+
messages: structuredClone(messages),
|
|
502
|
+
tools: structuredClone(tools),
|
|
503
|
+
model: workflow.target.model,
|
|
504
|
+
generation: {
|
|
505
|
+
maxTokens: Math.min(workflow.target.generation?.max_tokens ?? 1024, remainingTokens),
|
|
506
|
+
temperature: workflow.target.generation?.temperature ?? 0,
|
|
507
|
+
},
|
|
508
|
+
budgets: {
|
|
509
|
+
timeoutMs: Math.max(1, budget.timeout_ms - (Date.now() - started)),
|
|
510
|
+
maxToolCalls: 100,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
if (
|
|
514
|
+
!safeBoundary(request) ||
|
|
515
|
+
!agentTurnRequestSchema.safeParse(request).success ||
|
|
516
|
+
!validWorkflowTranscript(messages)
|
|
517
|
+
)
|
|
518
|
+
throw new Stop('invalid', 'transcript_limit');
|
|
519
|
+
admit('model');
|
|
520
|
+
const operationId = `model-${record.budgets.modelRequests}`;
|
|
521
|
+
emit({ type: 'model_requested', operationId });
|
|
522
|
+
let response: unknown;
|
|
523
|
+
let modelCompleted = false;
|
|
524
|
+
try {
|
|
525
|
+
try {
|
|
526
|
+
response = await owned(
|
|
527
|
+
() => target.turn(request, controller.signal),
|
|
528
|
+
controller.signal,
|
|
529
|
+
true
|
|
530
|
+
);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (error instanceof Stop) throw error;
|
|
533
|
+
throw new Stop('failed', 'target_error');
|
|
534
|
+
}
|
|
535
|
+
if (!safeBoundary(response)) {
|
|
536
|
+
record.usage.missingRequests++;
|
|
537
|
+
throw new Stop('invalid', 'invalid_response');
|
|
538
|
+
}
|
|
539
|
+
const failure = failureSchema.safeParse(response);
|
|
540
|
+
if (failure.success) {
|
|
541
|
+
if (
|
|
542
|
+
failure.data.tokens &&
|
|
543
|
+
failure.data.tokens.total ===
|
|
544
|
+
failure.data.tokens.prompt + failure.data.tokens.completion &&
|
|
545
|
+
failure.data.usageAvailable !== false &&
|
|
546
|
+
(failure.data.usageAvailable === true || failure.data.tokens.total > 0)
|
|
547
|
+
) {
|
|
548
|
+
measuredRequests++;
|
|
549
|
+
for (const key of ['prompt', 'completion', 'total'] as const) {
|
|
550
|
+
record.usage.reported[key] += failure.data.tokens[key];
|
|
551
|
+
if (phase === 'preflight') record.usage.preflight[key] += failure.data.tokens[key];
|
|
552
|
+
}
|
|
553
|
+
} else record.usage.missingRequests++;
|
|
554
|
+
if (failure.data.rejectedCall) {
|
|
555
|
+
const rejection = failure.data.rejectedCall;
|
|
556
|
+
const denied = rejection.reason === 'undeclared_tool';
|
|
557
|
+
if (denied) record.policy = 'denied';
|
|
558
|
+
admit('tool');
|
|
559
|
+
const rejectedOperationId = `tool-${record.budgets.toolCalls}`;
|
|
560
|
+
const tool = getWorkflowTool(rejection.tool)?.id ?? 'unknown';
|
|
561
|
+
emit({
|
|
562
|
+
type: 'tool_requested',
|
|
563
|
+
operationId: rejectedOperationId,
|
|
564
|
+
requestedCallIdHash: rejection.requestedCallIdHash,
|
|
565
|
+
tool,
|
|
566
|
+
});
|
|
567
|
+
emit({
|
|
568
|
+
type: 'tool_completed',
|
|
569
|
+
operationId: rejectedOperationId,
|
|
570
|
+
requestedCallIdHash: rejection.requestedCallIdHash,
|
|
571
|
+
tool,
|
|
572
|
+
status: denied ? 'denied' : 'invalid',
|
|
573
|
+
});
|
|
574
|
+
if (denied) throw new Stop('invalid', 'policy_denied');
|
|
575
|
+
}
|
|
576
|
+
if (failure.data.code === 'timeout') throw new Stop('timeout', 'deadline');
|
|
577
|
+
if (failure.data.code === 'aborted') throw new Stop('cancelled', 'cancelled');
|
|
578
|
+
throw new Stop(
|
|
579
|
+
failure.data.status === 'unsupported'
|
|
580
|
+
? 'unsupported'
|
|
581
|
+
: failure.data.status === 'invalid'
|
|
582
|
+
? 'invalid'
|
|
583
|
+
: 'failed',
|
|
584
|
+
failure.data.code === 'tool_use_unsupported'
|
|
585
|
+
? 'tool_use_unsupported'
|
|
586
|
+
: failure.data.status === 'invalid'
|
|
587
|
+
? 'invalid_response'
|
|
588
|
+
: 'target_error'
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
const parsed = completedSchema.safeParse(response);
|
|
592
|
+
if (
|
|
593
|
+
!parsed.success ||
|
|
594
|
+
parsed.data.tokens.total !== parsed.data.tokens.prompt + parsed.data.tokens.completion
|
|
595
|
+
) {
|
|
596
|
+
record.usage.missingRequests++;
|
|
597
|
+
throw new Stop('invalid', 'invalid_response');
|
|
598
|
+
}
|
|
599
|
+
const value = parsed.data;
|
|
600
|
+
const measured =
|
|
601
|
+
value.usageAvailable !== false && (value.usageAvailable === true || value.tokens.total > 0);
|
|
602
|
+
if (measured) {
|
|
603
|
+
measuredRequests++;
|
|
604
|
+
for (const key of ['prompt', 'completion', 'total'] as const) {
|
|
605
|
+
record.usage.reported[key] += value.tokens[key];
|
|
606
|
+
if (phase === 'preflight') record.usage.preflight[key] += value.tokens[key];
|
|
607
|
+
}
|
|
608
|
+
} else record.usage.missingRequests++;
|
|
609
|
+
record.capability.observedModelHash = hash(value.model);
|
|
610
|
+
record.capability.observedModel = identity(value.model);
|
|
611
|
+
const calls = value.message.tool_calls ?? [];
|
|
612
|
+
if (
|
|
613
|
+
(value.finishReason === 'tool_calls' && !calls.length) ||
|
|
614
|
+
(calls.length && value.finishReason && value.finishReason !== 'tool_calls')
|
|
615
|
+
)
|
|
616
|
+
throw new Stop('invalid', 'invalid_response');
|
|
617
|
+
for (const call of calls) {
|
|
618
|
+
if (ids.has(call.id)) throw new Stop('invalid', 'invalid_response');
|
|
619
|
+
ids.add(call.id);
|
|
620
|
+
}
|
|
621
|
+
modelCompleted = true;
|
|
622
|
+
emit({ type: 'model_completed', operationId, status: 'completed' });
|
|
623
|
+
if (budget.max_tokens !== undefined && !measured)
|
|
624
|
+
throw new Stop('budget_exceeded', 'usage_unavailable');
|
|
625
|
+
if (budget.max_tokens !== undefined && record.usage.reported.total > budget.max_tokens) {
|
|
626
|
+
record.budgets.tokenOvershoot = record.usage.reported.total - budget.max_tokens;
|
|
627
|
+
throw new Stop('budget_exceeded', 'max_tokens');
|
|
628
|
+
}
|
|
629
|
+
active();
|
|
630
|
+
return value;
|
|
631
|
+
} finally {
|
|
632
|
+
if (!modelCompleted) emit({ type: 'model_completed', operationId, status: 'failed' });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function originalArguments(
|
|
636
|
+
call: NonNullable<AgentTurnRequest['messages'][number]['tool_calls']>[number],
|
|
637
|
+
tools: AgentTurnRequest['tools']
|
|
638
|
+
): WorkflowState {
|
|
639
|
+
const definition = tools.find((entry) => entry.function.name === call.function.name);
|
|
640
|
+
if (!definition) {
|
|
641
|
+
record.policy = 'denied';
|
|
642
|
+
throw new Stop('invalid', 'policy_denied');
|
|
643
|
+
}
|
|
644
|
+
let input: unknown;
|
|
645
|
+
try {
|
|
646
|
+
input = JSON.parse(call.function.arguments);
|
|
647
|
+
} catch {
|
|
648
|
+
throw new Stop('invalid', 'invalid_response');
|
|
649
|
+
}
|
|
650
|
+
if (!isWorkflowState(input) || !ajv.compile(definition.function.parameters)(input))
|
|
651
|
+
throw new Stop('invalid', 'invalid_response');
|
|
652
|
+
return input;
|
|
653
|
+
}
|
|
654
|
+
async function probe() {
|
|
655
|
+
phase = 'preflight';
|
|
656
|
+
const nonce = randomUUID();
|
|
657
|
+
const tools: AgentTurnRequest['tools'] = [
|
|
658
|
+
{
|
|
659
|
+
type: 'function',
|
|
660
|
+
function: {
|
|
661
|
+
name: 'artemis_probe',
|
|
662
|
+
description: 'Echo the exact supplied nonce to verify structured tool protocol.',
|
|
663
|
+
parameters: {
|
|
664
|
+
type: 'object',
|
|
665
|
+
properties: { nonce: { const: nonce } },
|
|
666
|
+
required: ['nonce'],
|
|
667
|
+
additionalProperties: false,
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
];
|
|
672
|
+
const messages: AgentTurnRequest['messages'] = [
|
|
673
|
+
{
|
|
674
|
+
role: 'user',
|
|
675
|
+
content: `Call artemis_probe once with nonce ${nonce}. After receiving its result, reply with only that nonce.`,
|
|
676
|
+
},
|
|
677
|
+
];
|
|
678
|
+
const first = await model(messages, tools);
|
|
679
|
+
const calls = first.message.tool_calls ?? [];
|
|
680
|
+
if (calls.length !== 1) throw new Stop('unsupported', 'preflight_failed');
|
|
681
|
+
admit('tool');
|
|
682
|
+
const call = calls[0];
|
|
683
|
+
originalArguments(call, tools);
|
|
684
|
+
const operationId = `tool-${record.budgets.toolCalls}`;
|
|
685
|
+
const requestedCallIdHash = hash(call.id);
|
|
686
|
+
emit({ type: 'tool_requested', operationId, requestedCallIdHash, tool: 'artemis_probe' });
|
|
687
|
+
messages.push(first.message, {
|
|
688
|
+
role: 'tool',
|
|
689
|
+
toolCallId: call.id,
|
|
690
|
+
content: JSON.stringify({ nonce }),
|
|
691
|
+
});
|
|
692
|
+
emit({
|
|
693
|
+
type: 'tool_completed',
|
|
694
|
+
operationId,
|
|
695
|
+
requestedCallIdHash,
|
|
696
|
+
tool: 'artemis_probe',
|
|
697
|
+
status: 'completed',
|
|
698
|
+
});
|
|
699
|
+
const second = await model(messages, tools);
|
|
700
|
+
if (second.message.tool_calls?.length || second.message.content !== nonce)
|
|
701
|
+
throw new Stop('unsupported', 'preflight_failed');
|
|
702
|
+
record.capability.preflight = 'passed';
|
|
703
|
+
emit({ type: 'preflight_completed', status: 'completed' });
|
|
704
|
+
phase = 'execution';
|
|
705
|
+
}
|
|
706
|
+
async function executeTool(
|
|
707
|
+
call: NonNullable<AgentTurnRequest['messages'][number]['tool_calls']>[number],
|
|
708
|
+
tools: AgentTurnRequest['tools']
|
|
709
|
+
) {
|
|
710
|
+
if (!workflow || !environment) throw new Stop('failed', 'environment_unavailable');
|
|
711
|
+
admit('tool');
|
|
712
|
+
const operationId = `tool-${record.budgets.toolCalls}`;
|
|
713
|
+
const requestedCallIdHash = hash(call.id);
|
|
714
|
+
const known = getWorkflowTool(call.function.name);
|
|
715
|
+
emit({
|
|
716
|
+
type: 'tool_requested',
|
|
717
|
+
operationId,
|
|
718
|
+
requestedCallIdHash,
|
|
719
|
+
tool: known?.id ?? 'unknown',
|
|
720
|
+
});
|
|
721
|
+
let toolCompleted = false;
|
|
722
|
+
try {
|
|
723
|
+
const input = originalArguments(call, tools);
|
|
724
|
+
if (
|
|
725
|
+
!workflowToolPermitted(workflow, call.function.name) ||
|
|
726
|
+
!workflowPathAllowed(workflow, call.function.name, input)
|
|
727
|
+
) {
|
|
728
|
+
record.policy = 'denied';
|
|
729
|
+
toolCompleted = true;
|
|
730
|
+
emit({
|
|
731
|
+
type: 'tool_completed',
|
|
732
|
+
operationId,
|
|
733
|
+
requestedCallIdHash,
|
|
734
|
+
tool: known?.id ?? 'unknown',
|
|
735
|
+
status: 'denied',
|
|
736
|
+
});
|
|
737
|
+
throw new Stop('invalid', 'policy_denied');
|
|
738
|
+
}
|
|
739
|
+
let value: unknown;
|
|
740
|
+
try {
|
|
741
|
+
value = await owned(
|
|
742
|
+
() =>
|
|
743
|
+
environment
|
|
744
|
+
? environment.execute(
|
|
745
|
+
{ tool: call.function.name, input: structuredClone(input) },
|
|
746
|
+
controller.signal
|
|
747
|
+
)
|
|
748
|
+
: Promise.reject(),
|
|
749
|
+
controller.signal
|
|
750
|
+
);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (error instanceof Stop) throw error;
|
|
753
|
+
throw new Stop('failed', 'tool_failed');
|
|
754
|
+
}
|
|
755
|
+
if (!isWorkflowState(value) || !known) throw new Stop('invalid', 'invalid_environment');
|
|
756
|
+
const status = value.status;
|
|
757
|
+
if (
|
|
758
|
+
!isWorkflowState(value.evidence) ||
|
|
759
|
+
value.evidence.tool !== known.id ||
|
|
760
|
+
value.evidence.version !== '1' ||
|
|
761
|
+
value.evidence.status !== status ||
|
|
762
|
+
Object.keys(value).some(
|
|
763
|
+
(key) =>
|
|
764
|
+
!(
|
|
765
|
+
status === 'succeeded'
|
|
766
|
+
? ['status', 'output', 'state', 'evidence']
|
|
767
|
+
: ['status', 'code', 'evidence']
|
|
768
|
+
).includes(key)
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
throw new Stop('invalid', 'invalid_environment');
|
|
772
|
+
if (status === 'succeeded') {
|
|
773
|
+
if (
|
|
774
|
+
!isWorkflowState(value.state) ||
|
|
775
|
+
!isWorkflowJson(value.output) ||
|
|
776
|
+
!ajv.compile(known.outputSchema)(value.output)
|
|
777
|
+
)
|
|
778
|
+
throw new Stop('invalid', 'invalid_environment');
|
|
779
|
+
state = structuredClone(value.state);
|
|
780
|
+
transcript.push({
|
|
781
|
+
role: 'tool',
|
|
782
|
+
toolCallId: call.id,
|
|
783
|
+
content: JSON.stringify(value.output),
|
|
784
|
+
});
|
|
785
|
+
toolCompleted = true;
|
|
786
|
+
emit({
|
|
787
|
+
type: 'tool_completed',
|
|
788
|
+
operationId,
|
|
789
|
+
requestedCallIdHash,
|
|
790
|
+
tool: known.id,
|
|
791
|
+
status: 'completed',
|
|
792
|
+
});
|
|
793
|
+
} else if (
|
|
794
|
+
(status === 'denied' || status === 'invalid' || status === 'failed') &&
|
|
795
|
+
typeof value.code === 'string' &&
|
|
796
|
+
failureCodes.has(value.code)
|
|
797
|
+
) {
|
|
798
|
+
if (status === 'denied') record.policy = 'denied';
|
|
799
|
+
transcript.push({
|
|
800
|
+
role: 'tool',
|
|
801
|
+
toolCallId: call.id,
|
|
802
|
+
content: JSON.stringify({ status, code: value.code }),
|
|
803
|
+
});
|
|
804
|
+
toolCompleted = true;
|
|
805
|
+
emit({ type: 'tool_completed', operationId, requestedCallIdHash, tool: known.id, status });
|
|
806
|
+
throw new Stop(
|
|
807
|
+
status === 'denied' ? 'invalid' : 'failed',
|
|
808
|
+
status === 'denied' ? 'policy_denied' : 'tool_failed'
|
|
809
|
+
);
|
|
810
|
+
} else throw new Stop('invalid', 'invalid_environment');
|
|
811
|
+
active();
|
|
812
|
+
} finally {
|
|
813
|
+
if (!toolCompleted)
|
|
814
|
+
emit({
|
|
815
|
+
type: 'tool_completed',
|
|
816
|
+
operationId,
|
|
817
|
+
requestedCallIdHash,
|
|
818
|
+
tool: known?.id ?? 'unknown',
|
|
819
|
+
status: record.policy === 'denied' ? 'denied' : 'failed',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async function run(): Promise<AgentWorkflowResult> {
|
|
824
|
+
sessionState = controller.signal.aborted ? 'cancelling' : 'running';
|
|
825
|
+
started = Date.now();
|
|
826
|
+
const timeout = workflow?.environment.policy.budgets.timeout_ms ?? 1;
|
|
827
|
+
const timer = setTimeout(() => {
|
|
828
|
+
deadlineExpired = true;
|
|
829
|
+
abort();
|
|
830
|
+
}, timeout);
|
|
831
|
+
const cleanupMs =
|
|
832
|
+
options.cleanupTimeoutMs ?? (workflow?.environment.type === 'sandbox' ? 6000 : 1000);
|
|
833
|
+
try {
|
|
834
|
+
if (!workflow) throw new Stop('invalid', 'invalid_workflow');
|
|
835
|
+
if (
|
|
836
|
+
!Number.isInteger(cleanupMs) ||
|
|
837
|
+
cleanupMs < 1 ||
|
|
838
|
+
cleanupMs > 10_000 ||
|
|
839
|
+
!target ||
|
|
840
|
+
typeof target.turn !== 'function' ||
|
|
841
|
+
typeof target.capabilities !== 'function'
|
|
842
|
+
)
|
|
843
|
+
throw new Stop('invalid', 'invalid_options');
|
|
844
|
+
active();
|
|
845
|
+
emit({ type: 'started' });
|
|
846
|
+
const initialState = options.preflightOnly
|
|
847
|
+
? {}
|
|
848
|
+
: await owned(
|
|
849
|
+
() => resolveWorkflowInitialState(workflow as AgentWorkflow, options.fixtureRoot),
|
|
850
|
+
controller.signal
|
|
851
|
+
).catch((error) => {
|
|
852
|
+
if (error instanceof Stop) throw error;
|
|
853
|
+
throw new Stop('invalid', 'invalid_fixture');
|
|
854
|
+
});
|
|
855
|
+
active();
|
|
856
|
+
const advertised = await owned(
|
|
857
|
+
() =>
|
|
858
|
+
target.capabilities(
|
|
859
|
+
{ timeoutMs: Math.max(1, timeout - (Date.now() - started)) },
|
|
860
|
+
controller.signal
|
|
861
|
+
),
|
|
862
|
+
controller.signal
|
|
863
|
+
);
|
|
864
|
+
if (!safeBoundary(advertised)) throw new Stop('invalid', 'invalid_response');
|
|
865
|
+
const capability = capabilitySchema.safeParse(advertised);
|
|
866
|
+
if (!capability.success) throw new Stop('unsupported', 'target_unavailable');
|
|
867
|
+
record.capability.advertised = capability.data.toolUse;
|
|
868
|
+
record.capability.transportCancellation = capability.data.transportCancellation;
|
|
869
|
+
if (!capability.data.toolUse) throw new Stop('unsupported', 'tool_use_unsupported');
|
|
870
|
+
if (options.preflight || options.preflightOnly) await probe();
|
|
871
|
+
if (!options.preflightOnly) {
|
|
872
|
+
active();
|
|
873
|
+
const factory =
|
|
874
|
+
options.environmentFactory ??
|
|
875
|
+
(workflow.environment.type === 'simulated'
|
|
876
|
+
? createSimulatedWorkflowEnvironment
|
|
877
|
+
: createDockerWorkflowEnvironment);
|
|
878
|
+
environment = await owned(async () => {
|
|
879
|
+
const created = await factory({
|
|
880
|
+
workflow: structuredClone(workflow as AgentWorkflow),
|
|
881
|
+
initialState: structuredClone(initialState),
|
|
882
|
+
signal: controller.signal,
|
|
883
|
+
});
|
|
884
|
+
// Retain late creation so cleanup can close it after deadline/cancellation.
|
|
885
|
+
environment = created;
|
|
886
|
+
if (finished && created && typeof created.close === 'function') {
|
|
887
|
+
const lateController = new AbortController();
|
|
888
|
+
const lateTimer = setTimeout(() => lateController.abort(), Math.min(1000, cleanupMs));
|
|
889
|
+
try {
|
|
890
|
+
await owned(() => created.close(lateController.signal), lateController.signal);
|
|
891
|
+
} catch {
|
|
892
|
+
/* Returned record already marks unresolved resources. */
|
|
893
|
+
} finally {
|
|
894
|
+
clearTimeout(lateTimer);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return created;
|
|
898
|
+
}, controller.signal);
|
|
899
|
+
if (
|
|
900
|
+
!environment ||
|
|
901
|
+
environment.type !== workflow.environment.type ||
|
|
902
|
+
typeof environment.execute !== 'function' ||
|
|
903
|
+
typeof environment.snapshot !== 'function' ||
|
|
904
|
+
typeof environment.close !== 'function' ||
|
|
905
|
+
environment.capabilities?.network !== 'denied' ||
|
|
906
|
+
environment.capabilities.commands !== 'denied' ||
|
|
907
|
+
environment.capabilities.externalSideEffects !== 'denied' ||
|
|
908
|
+
environment.capabilities.isolation !==
|
|
909
|
+
(workflow.environment.type === 'simulated' ? 'memory' : 'container')
|
|
910
|
+
)
|
|
911
|
+
throw new Stop('invalid', 'invalid_environment');
|
|
912
|
+
state = structuredClone(initialState);
|
|
913
|
+
transcript = [{ role: 'system', content: workflow.workflow.system_instructions }];
|
|
914
|
+
const tools: AgentTurnRequest['tools'] = workflow.tools.map((id) => {
|
|
915
|
+
const descriptor = getWorkflowTool(id);
|
|
916
|
+
if (!descriptor) throw new Stop('invalid', 'invalid_workflow');
|
|
917
|
+
return {
|
|
918
|
+
type: 'function',
|
|
919
|
+
function: {
|
|
920
|
+
name: id,
|
|
921
|
+
description: descriptor.description,
|
|
922
|
+
parameters: descriptor.inputSchema,
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
});
|
|
926
|
+
for (const turn of workflow.workflow.turns) {
|
|
927
|
+
transcript.push(structuredClone(turn));
|
|
928
|
+
while (true) {
|
|
929
|
+
const answer = await model(transcript, tools);
|
|
930
|
+
transcript.push(answer.message);
|
|
931
|
+
const calls = answer.message.tool_calls ?? [];
|
|
932
|
+
if (!calls.length) break;
|
|
933
|
+
for (const call of calls) await executeTool(call, tools);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
record.execution = 'completed';
|
|
938
|
+
record.reason = 'finished';
|
|
939
|
+
} catch (error) {
|
|
940
|
+
if (error instanceof WorkflowEnvironmentInitializationError) {
|
|
941
|
+
const detail = error.cleanup;
|
|
942
|
+
const checked = safeBoundary(detail)
|
|
943
|
+
? cleanupSchema
|
|
944
|
+
.extend({ pendingOperations: z.number().int().nonnegative().safe() })
|
|
945
|
+
.safeParse(detail)
|
|
946
|
+
: null;
|
|
947
|
+
record.cleanup = checked?.success
|
|
948
|
+
? checked.data
|
|
949
|
+
: { status: 'unresolved', artifacts: 'unknown', pendingOperations: 1 };
|
|
950
|
+
}
|
|
951
|
+
const stop =
|
|
952
|
+
error instanceof WorkflowEnvironmentInitializationError
|
|
953
|
+
? new Stop('failed', 'environment_unavailable')
|
|
954
|
+
: error instanceof Stop
|
|
955
|
+
? error
|
|
956
|
+
: new Stop('failed', 'target_error');
|
|
957
|
+
record.execution = stop.execution;
|
|
958
|
+
record.reason = stop.reason;
|
|
959
|
+
} finally {
|
|
960
|
+
clearTimeout(timer);
|
|
961
|
+
controller.abort();
|
|
962
|
+
const cleanup = new AbortController();
|
|
963
|
+
const drainController = new AbortController();
|
|
964
|
+
const drainBudget = Math.max(
|
|
965
|
+
1,
|
|
966
|
+
Math.floor(
|
|
967
|
+
(Number.isInteger(cleanupMs) && cleanupMs >= 1 && cleanupMs <= 10_000
|
|
968
|
+
? cleanupMs
|
|
969
|
+
: 1000) / 3
|
|
970
|
+
)
|
|
971
|
+
);
|
|
972
|
+
const drainTimer = setTimeout(() => drainController.abort(), drainBudget);
|
|
973
|
+
let adapterPending = 0;
|
|
974
|
+
try {
|
|
975
|
+
const drain = target?.drain;
|
|
976
|
+
if (typeof drain === 'function') {
|
|
977
|
+
const value = await owned(
|
|
978
|
+
() => drain.call(target, { timeoutMs: drainBudget }),
|
|
979
|
+
drainController.signal
|
|
980
|
+
);
|
|
981
|
+
if (
|
|
982
|
+
value &&
|
|
983
|
+
Number.isSafeInteger(value.pendingOperations) &&
|
|
984
|
+
value.pendingOperations >= 0
|
|
985
|
+
)
|
|
986
|
+
adapterPending = value.pendingOperations;
|
|
987
|
+
else adapterPending = 1;
|
|
988
|
+
}
|
|
989
|
+
const callbacks = [...pending];
|
|
990
|
+
if (callbacks.length)
|
|
991
|
+
await owned(() => Promise.allSettled(callbacks), drainController.signal);
|
|
992
|
+
} catch {
|
|
993
|
+
adapterPending =
|
|
994
|
+
typeof target?.drain === 'function' ? Math.max(1, adapterPending) : adapterPending;
|
|
995
|
+
}
|
|
996
|
+
clearTimeout(drainTimer);
|
|
997
|
+
record.usage.inFlightUnknown = modelPending.size > 0 || adapterPending > 0;
|
|
998
|
+
const pendingBeforeClose = pending.size;
|
|
999
|
+
if (environment && typeof environment.close === 'function') {
|
|
1000
|
+
const snapshotController = new AbortController();
|
|
1001
|
+
const snapshotTimer = setTimeout(() => snapshotController.abort(), drainBudget);
|
|
1002
|
+
if (
|
|
1003
|
+
!pendingBeforeClose &&
|
|
1004
|
+
typeof environment.snapshot === 'function' &&
|
|
1005
|
+
!cleanup.signal.aborted
|
|
1006
|
+
) {
|
|
1007
|
+
try {
|
|
1008
|
+
const snapshot = await owned(
|
|
1009
|
+
() => (environment as WorkflowEnvironment).snapshot(snapshotController.signal),
|
|
1010
|
+
snapshotController.signal
|
|
1011
|
+
);
|
|
1012
|
+
if (isWorkflowState(snapshot)) state = structuredClone(snapshot);
|
|
1013
|
+
else state = null;
|
|
1014
|
+
} catch {
|
|
1015
|
+
state = null;
|
|
1016
|
+
}
|
|
1017
|
+
} else state = null;
|
|
1018
|
+
clearTimeout(snapshotTimer);
|
|
1019
|
+
const closeTimer = setTimeout(() => cleanup.abort(), drainBudget);
|
|
1020
|
+
try {
|
|
1021
|
+
const result = await owned(
|
|
1022
|
+
() => (environment as WorkflowEnvironment).close(cleanup.signal),
|
|
1023
|
+
cleanup.signal
|
|
1024
|
+
);
|
|
1025
|
+
const checked = safeBoundary(result) ? cleanupSchema.safeParse(result) : null;
|
|
1026
|
+
if (checked?.success) record.cleanup = { ...checked.data, pendingOperations: 0 };
|
|
1027
|
+
else
|
|
1028
|
+
record.cleanup = { status: 'unresolved', artifacts: 'unknown', pendingOperations: 0 };
|
|
1029
|
+
} catch {
|
|
1030
|
+
record.cleanup = { status: 'unresolved', artifacts: 'unknown', pendingOperations: 0 };
|
|
1031
|
+
} finally {
|
|
1032
|
+
clearTimeout(closeTimer);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
record.cleanup.pendingOperations = Math.max(
|
|
1036
|
+
record.cleanup.pendingOperations,
|
|
1037
|
+
pending.size,
|
|
1038
|
+
adapterPending
|
|
1039
|
+
);
|
|
1040
|
+
if (record.cleanup.pendingOperations || pendingBeforeClose || cleanup.signal.aborted) {
|
|
1041
|
+
record.cleanup.status = 'unresolved';
|
|
1042
|
+
record.cleanup.artifacts = 'unknown';
|
|
1043
|
+
}
|
|
1044
|
+
if (record.cleanup.status === 'unresolved') state = null;
|
|
1045
|
+
|
|
1046
|
+
options.signal?.removeEventListener('abort', abort);
|
|
1047
|
+
record.usage.missingRequests = record.budgets.modelRequests - measuredRequests;
|
|
1048
|
+
const tokenLimit = workflow?.environment.policy.budgets.max_tokens;
|
|
1049
|
+
record.budgets.tokenOvershoot =
|
|
1050
|
+
tokenLimit === undefined ? 0 : Math.max(0, record.usage.reported.total - tokenLimit);
|
|
1051
|
+
record.usage.status = measuredRequests
|
|
1052
|
+
? record.usage.missingRequests || record.usage.inFlightUnknown
|
|
1053
|
+
? 'partial'
|
|
1054
|
+
: 'reported'
|
|
1055
|
+
: 'unavailable';
|
|
1056
|
+
if (state) {
|
|
1057
|
+
const files = isWorkflowState(state.files)
|
|
1058
|
+
? Object.entries(state.files)
|
|
1059
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
1060
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1061
|
+
: [];
|
|
1062
|
+
record.artifacts = {
|
|
1063
|
+
state: 'available',
|
|
1064
|
+
stateSha256: hash(JSON.stringify(state)),
|
|
1065
|
+
files: files.slice(0, 100).map(([path, content]) => ({
|
|
1066
|
+
pathSha256: hash(path),
|
|
1067
|
+
contentSha256: hash(content),
|
|
1068
|
+
bytes: Buffer.byteLength(content),
|
|
1069
|
+
})),
|
|
1070
|
+
omittedFiles: Math.max(0, files.length - 100),
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
record.budgets.elapsedMs = Math.max(0, Date.now() - started);
|
|
1074
|
+
emit({ type: 'finished', status: record.execution === 'completed' ? 'completed' : 'failed' });
|
|
1075
|
+
finished = true;
|
|
1076
|
+
sessionState = 'completed';
|
|
1077
|
+
for (const wake of listeners) wake();
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
record: structuredClone(record),
|
|
1081
|
+
state: state ? structuredClone(state) : null,
|
|
1082
|
+
transcript: structuredClone(transcript),
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
get state() {
|
|
1087
|
+
return sessionState;
|
|
1088
|
+
},
|
|
1089
|
+
run() {
|
|
1090
|
+
if (!promise) {
|
|
1091
|
+
sessionState = controller.signal.aborted ? 'cancelling' : 'running';
|
|
1092
|
+
// Assign before emitting events so a reentrant observer cannot start a second run.
|
|
1093
|
+
promise = Promise.resolve().then(run);
|
|
1094
|
+
}
|
|
1095
|
+
return promise;
|
|
1096
|
+
},
|
|
1097
|
+
cancel: abort,
|
|
1098
|
+
async *events() {
|
|
1099
|
+
let index = 0;
|
|
1100
|
+
while (true) {
|
|
1101
|
+
while (index < retained.length) yield structuredClone(retained[index++]);
|
|
1102
|
+
if (finished) return;
|
|
1103
|
+
await new Promise<void>((resolve) => {
|
|
1104
|
+
const wake = () => {
|
|
1105
|
+
listeners.delete(wake);
|
|
1106
|
+
resolve();
|
|
1107
|
+
};
|
|
1108
|
+
listeners.add(wake);
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
export function runAgentWorkflow(
|
|
1116
|
+
options: AgentWorkflowSessionOptions
|
|
1117
|
+
): Promise<AgentWorkflowResult> {
|
|
1118
|
+
return createAgentWorkflowSession(options).run();
|
|
1119
|
+
}
|