@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +12 -0
  3. package/dist/adapters/types.d.ts +7 -0
  4. package/dist/adapters/types.d.ts.map +1 -1
  5. package/dist/agent-workflow/catalog.d.ts +2 -1
  6. package/dist/agent-workflow/catalog.d.ts.map +1 -1
  7. package/dist/agent-workflow/environment.d.ts +47 -0
  8. package/dist/agent-workflow/environment.d.ts.map +1 -0
  9. package/dist/agent-workflow/index.d.ts +4 -1
  10. package/dist/agent-workflow/index.d.ts.map +1 -1
  11. package/dist/agent-workflow/parser.d.ts +1 -1
  12. package/dist/agent-workflow/parser.d.ts.map +1 -1
  13. package/dist/agent-workflow/sandbox-fixtures/qualify.d.ts +2 -0
  14. package/dist/agent-workflow/sandbox-fixtures/qualify.d.ts.map +1 -0
  15. package/dist/agent-workflow/sandbox.d.ts +12 -0
  16. package/dist/agent-workflow/sandbox.d.ts.map +1 -0
  17. package/dist/agent-workflow/schema.d.ts +117 -7
  18. package/dist/agent-workflow/schema.d.ts.map +1 -1
  19. package/dist/agent-workflow/session.d.ts +113 -0
  20. package/dist/agent-workflow/session.d.ts.map +1 -0
  21. package/dist/agent-workflow/target.d.ts +20 -7
  22. package/dist/agent-workflow/target.d.ts.map +1 -1
  23. package/dist/index.js +1366 -21
  24. package/package.json +1 -1
  25. package/src/adapters/types.ts +7 -0
  26. package/src/agent-workflow/catalog.ts +4 -3
  27. package/src/agent-workflow/environment.ts +207 -0
  28. package/src/agent-workflow/index.ts +5 -1
  29. package/src/agent-workflow/parser.ts +1 -1
  30. package/src/agent-workflow/sandbox-fixtures/qualify.ts +305 -0
  31. package/src/agent-workflow/sandbox.test.ts +117 -0
  32. package/src/agent-workflow/sandbox.ts +438 -0
  33. package/src/agent-workflow/schema.ts +18 -2
  34. package/src/agent-workflow/session.test.ts +629 -0
  35. package/src/agent-workflow/session.ts +1119 -0
  36. package/src/agent-workflow/target.test.ts +5 -1
  37. 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
+ }