@artemiskit/core 0.5.3 → 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 +28 -0
- package/README.md +12 -0
- package/dist/adapters/factory.d.ts +1 -1
- package/dist/adapters/index.d.ts +3 -3
- package/dist/adapters/registry.d.ts +1 -1
- package/dist/adapters/types.d.ts +7 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/agent-evaluation/index.d.ts +2 -2
- package/dist/agent-evaluation/scorer.d.ts +1 -1
- package/dist/agent-workflow/catalog.d.ts +28 -0
- package/dist/agent-workflow/catalog.d.ts.map +1 -0
- 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 +10 -0
- package/dist/agent-workflow/index.d.ts.map +1 -0
- package/dist/agent-workflow/parser.d.ts +7 -0
- package/dist/agent-workflow/parser.d.ts.map +1 -0
- 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 +774 -0
- package/dist/agent-workflow/schema.d.ts.map +1 -0
- package/dist/agent-workflow/session.d.ts +113 -0
- package/dist/agent-workflow/session.d.ts.map +1 -0
- package/dist/agent-workflow/simulated-tools.d.ts +35 -0
- package/dist/agent-workflow/simulated-tools.d.ts.map +1 -0
- package/dist/agent-workflow/target.d.ts +243 -0
- package/dist/agent-workflow/target.d.ts.map +1 -0
- package/dist/artifacts/index.d.ts +2 -2
- package/dist/artifacts/manifest.d.ts +1 -1
- package/dist/artifacts/types.d.ts +2 -2
- package/dist/comparison/eligibility.d.ts +1 -1
- package/dist/comparison/index.d.ts +1 -1
- package/dist/evaluators/combined.d.ts +2 -2
- package/dist/evaluators/contains.d.ts +2 -2
- package/dist/evaluators/exact.d.ts +2 -2
- package/dist/evaluators/fuzzy.d.ts +2 -2
- package/dist/evaluators/index.d.ts +13 -13
- package/dist/evaluators/inline.d.ts +2 -2
- package/dist/evaluators/json-schema.d.ts +2 -2
- package/dist/evaluators/llm-grader.d.ts +2 -2
- package/dist/evaluators/not-contains.d.ts +2 -2
- package/dist/evaluators/regex.d.ts +2 -2
- package/dist/evaluators/similarity.d.ts +2 -2
- package/dist/evaluators/tool-trace.d.ts +2 -2
- package/dist/evaluators/types.d.ts +3 -3
- package/dist/index.d.ts +15 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17827 -15174
- package/dist/provenance/execution-provenance.d.ts +1 -1
- package/dist/provenance/git.d.ts +1 -1
- package/dist/provenance/index.d.ts +4 -4
- package/dist/provenance/workload-identity.d.ts +2 -2
- package/dist/redaction/index.d.ts +2 -2
- package/dist/redaction/redactor.d.ts +1 -1
- package/dist/runner/executor.d.ts +3 -3
- package/dist/runner/index.d.ts +3 -3
- package/dist/runner/runner.d.ts +1 -1
- package/dist/runner/types.d.ts +5 -5
- package/dist/scenario/index.d.ts +4 -4
- package/dist/scenario/parser.d.ts +1 -1
- package/dist/scenario/variables.d.ts +1 -1
- package/dist/storage/factory.d.ts +1 -1
- package/dist/storage/index.d.ts +4 -4
- package/dist/storage/local.d.ts +2 -2
- package/dist/storage/supabase.d.ts +2 -2
- package/dist/storage/types.d.ts +2 -2
- package/dist/tools/fixture-executor.d.ts +2 -2
- package/dist/tools/index.d.ts +3 -3
- package/dist/tools/types.d.ts +1 -1
- package/dist/utils/index.d.ts +2 -2
- package/dist/validator/index.d.ts +2 -2
- package/dist/validator/validator.d.ts +1 -1
- package/package.json +4 -4
- package/src/adapters/types.ts +7 -0
- package/src/agent-workflow/catalog.ts +220 -0
- package/src/agent-workflow/environment.ts +207 -0
- package/src/agent-workflow/index.ts +10 -0
- package/src/agent-workflow/parser.ts +43 -0
- 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.test.ts +218 -0
- package/src/agent-workflow/schema.ts +270 -0
- package/src/agent-workflow/session.test.ts +629 -0
- package/src/agent-workflow/session.ts +1119 -0
- package/src/agent-workflow/simulated-tools.test.ts +177 -0
- package/src/agent-workflow/simulated-tools.ts +230 -0
- package/src/agent-workflow/target.test.ts +280 -0
- package/src/agent-workflow/target.ts +357 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { ToolCall } from '../adapters/types';
|
|
6
|
+
import {
|
|
7
|
+
type WorkflowEnvironmentFactory,
|
|
8
|
+
WorkflowEnvironmentInitializationError,
|
|
9
|
+
createSimulatedWorkflowEnvironment,
|
|
10
|
+
resolveWorkflowInitialState,
|
|
11
|
+
} from './environment';
|
|
12
|
+
import { validateAgentWorkflow } from './parser';
|
|
13
|
+
import type { AgentWorkflow } from './schema';
|
|
14
|
+
import { createAgentWorkflowSession, runAgentWorkflow } from './session';
|
|
15
|
+
import {
|
|
16
|
+
type AgentTarget,
|
|
17
|
+
type AgentTurnRequest,
|
|
18
|
+
type AgentTurnResult,
|
|
19
|
+
createModelClientTarget,
|
|
20
|
+
} from './target';
|
|
21
|
+
|
|
22
|
+
function fixture(): AgentWorkflow {
|
|
23
|
+
return validateAgentWorkflow({
|
|
24
|
+
version: '1',
|
|
25
|
+
kind: 'agent_workflow',
|
|
26
|
+
name: 'session',
|
|
27
|
+
target: { provider: 'custom', model: 'fixture-model' },
|
|
28
|
+
environment: {
|
|
29
|
+
type: 'simulated',
|
|
30
|
+
policy: {
|
|
31
|
+
network: 'denied',
|
|
32
|
+
side_effects: 'approval_required',
|
|
33
|
+
permissions: { files: 'write', workflow_state: 'write' },
|
|
34
|
+
budgets: { max_actions: 30, timeout_ms: 1000 },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
tools: ['calculator', 'read_file', 'write_file', 'request_approval'],
|
|
38
|
+
workflow: {
|
|
39
|
+
system_instructions: 'Use structured tools.',
|
|
40
|
+
initial_state: { files: { 'note.txt': 'PRIVATE-CONTENT' }, workflow_state: {} },
|
|
41
|
+
turns: [{ role: 'user', content: 'Complete task.' }],
|
|
42
|
+
},
|
|
43
|
+
outcomes: {
|
|
44
|
+
deterministic: [{ type: 'policy', rule: 'permissions_respected', expected: 'passed' }],
|
|
45
|
+
},
|
|
46
|
+
evidence: { trace: 'summary', artifacts: 'checksums', redact: true },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function call(
|
|
50
|
+
name = 'calculator',
|
|
51
|
+
args: unknown = { operation: 'add', a: 1, b: 2 },
|
|
52
|
+
id = 'call-1'
|
|
53
|
+
): ToolCall {
|
|
54
|
+
return { id, type: 'function', function: { name, arguments: JSON.stringify(args) } };
|
|
55
|
+
}
|
|
56
|
+
function answer(
|
|
57
|
+
calls: ToolCall[] = [],
|
|
58
|
+
text = 'done'
|
|
59
|
+
): Extract<AgentTurnResult, { status: 'completed' }> {
|
|
60
|
+
return {
|
|
61
|
+
status: 'completed',
|
|
62
|
+
id: 'response',
|
|
63
|
+
model: 'fixture-model',
|
|
64
|
+
message: { role: 'assistant', content: text, ...(calls.length ? { tool_calls: calls } : {}) },
|
|
65
|
+
tokens: { prompt: 2, completion: 1, total: 3 },
|
|
66
|
+
latencyMs: 1,
|
|
67
|
+
finishReason: calls.length ? 'tool_calls' : 'stop',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function target(
|
|
71
|
+
responses:
|
|
72
|
+
| AgentTurnResult[]
|
|
73
|
+
| ((request: AgentTurnRequest, index: number, signal?: AbortSignal) => Promise<AgentTurnResult>)
|
|
74
|
+
): AgentTarget & { requests: AgentTurnRequest[] } {
|
|
75
|
+
const requests: AgentTurnRequest[] = [];
|
|
76
|
+
return {
|
|
77
|
+
provider: 'custom',
|
|
78
|
+
requests,
|
|
79
|
+
capabilities: async () => ({
|
|
80
|
+
status: 'available',
|
|
81
|
+
toolUse: true,
|
|
82
|
+
transportCancellation: false,
|
|
83
|
+
}),
|
|
84
|
+
turn: async (request, signal) => {
|
|
85
|
+
requests.push(structuredClone(request));
|
|
86
|
+
return typeof responses === 'function'
|
|
87
|
+
? responses(request, requests.length - 1, signal)
|
|
88
|
+
: structuredClone(responses[requests.length - 1] ?? answer());
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const never = <T>(): Promise<T> => new Promise(() => {});
|
|
93
|
+
|
|
94
|
+
describe('native workflow session host', () => {
|
|
95
|
+
test('multi-turn tool correlation, reusable session and isolated working inputs', async () => {
|
|
96
|
+
const workflow = fixture();
|
|
97
|
+
workflow.workflow.turns.push({ role: 'user', content: 'Explain.' });
|
|
98
|
+
const before = structuredClone(workflow);
|
|
99
|
+
const t = target([
|
|
100
|
+
answer([call('write_file', { path: 'note.txt', content: 'updated' })]),
|
|
101
|
+
answer(),
|
|
102
|
+
answer(),
|
|
103
|
+
]);
|
|
104
|
+
const session = createAgentWorkflowSession({ workflow, target: t });
|
|
105
|
+
expect(session.state).toBe('idle');
|
|
106
|
+
const first = session.run();
|
|
107
|
+
expect(session.run()).toBe(first);
|
|
108
|
+
const result = await first;
|
|
109
|
+
expect(result.record.execution).toBe('completed');
|
|
110
|
+
expect(result.record.taskVerification).toBe('unavailable');
|
|
111
|
+
expect(result.state?.files).toEqual({ 'note.txt': 'updated' });
|
|
112
|
+
expect(workflow).toEqual(before);
|
|
113
|
+
expect(t.requests[1].messages.at(-1)).toMatchObject({ role: 'tool', toolCallId: 'call-1' });
|
|
114
|
+
expect(t.requests[2].messages.at(-1)).toEqual({ role: 'user', content: 'Explain.' });
|
|
115
|
+
expect(result.record.budgets).toMatchObject({ actions: 4, modelRequests: 3, toolCalls: 1 });
|
|
116
|
+
expect(result.record.artifacts.files).toHaveLength(1);
|
|
117
|
+
expect((await runAgentWorkflow({ workflow, target: target([answer()]) })).state?.files).toEqual(
|
|
118
|
+
{ 'note.txt': 'PRIVATE-CONTENT' }
|
|
119
|
+
);
|
|
120
|
+
expect(session.state).toBe('completed');
|
|
121
|
+
const events = [];
|
|
122
|
+
for await (const event of session.events()) events.push(event);
|
|
123
|
+
expect(events).toEqual(result.record.events);
|
|
124
|
+
});
|
|
125
|
+
test('reentrant run from an event callback shares the original execution', async () => {
|
|
126
|
+
const t = target([answer()]);
|
|
127
|
+
let observed = false;
|
|
128
|
+
let repeated: Promise<unknown> | undefined;
|
|
129
|
+
const session = createAgentWorkflowSession({
|
|
130
|
+
workflow: fixture(),
|
|
131
|
+
target: t,
|
|
132
|
+
onEvent(event) {
|
|
133
|
+
if (event.type === 'started' && !observed) {
|
|
134
|
+
observed = true;
|
|
135
|
+
repeated = session.run();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const first = session.run();
|
|
140
|
+
await first;
|
|
141
|
+
expect(repeated).toBe(first);
|
|
142
|
+
expect(t.requests).toHaveLength(1);
|
|
143
|
+
});
|
|
144
|
+
test('approval remains pending, never granted', async () => {
|
|
145
|
+
const result = await runAgentWorkflow({
|
|
146
|
+
workflow: fixture(),
|
|
147
|
+
target: target([answer([call('request_approval', { reason: 'Review required' })]), answer()]),
|
|
148
|
+
});
|
|
149
|
+
expect(result.state?.workflow_state).toEqual({
|
|
150
|
+
approvals: { requested: true, status: 'pending', reason: 'Review required' },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
test.each(['max_actions', 'max_model_requests', 'max_tool_calls'] as const)(
|
|
154
|
+
'enforces cumulative %s',
|
|
155
|
+
async (limit) => {
|
|
156
|
+
const workflow = fixture();
|
|
157
|
+
workflow.environment.policy.budgets[limit] = 1;
|
|
158
|
+
const t = target([
|
|
159
|
+
answer([call(), call('calculator', { operation: 'add', a: 2, b: 3 }, 'call-2')]),
|
|
160
|
+
answer(),
|
|
161
|
+
]);
|
|
162
|
+
const result = await runAgentWorkflow({ workflow, target: t });
|
|
163
|
+
expect(result.record.execution).toBe('budget_exceeded');
|
|
164
|
+
expect(result.record.reason).toBe(limit);
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
test('caps requested output and reports token overshoot without executing returned tools', async () => {
|
|
168
|
+
const workflow = fixture();
|
|
169
|
+
workflow.environment.policy.budgets.max_tokens = 2;
|
|
170
|
+
const t = target([answer([call()])]);
|
|
171
|
+
const result = await runAgentWorkflow({ workflow, target: t });
|
|
172
|
+
expect(t.requests[0].generation.maxTokens).toBe(2);
|
|
173
|
+
expect(result.record.budgets.tokenOvershoot).toBe(1);
|
|
174
|
+
expect(result.record.budgets.toolCalls).toBe(0);
|
|
175
|
+
expect(result.record.reason).toBe('max_tokens');
|
|
176
|
+
});
|
|
177
|
+
test.each([true, false])(
|
|
178
|
+
'missing usage fails closed with returned tools=%s when token budget is configured',
|
|
179
|
+
async (withTools) => {
|
|
180
|
+
const workflow = fixture();
|
|
181
|
+
workflow.environment.policy.budgets.max_tokens = 20;
|
|
182
|
+
const response = {
|
|
183
|
+
...answer(withTools ? [call()] : []),
|
|
184
|
+
tokens: { prompt: 0, completion: 0, total: 0 },
|
|
185
|
+
usageAvailable: false,
|
|
186
|
+
};
|
|
187
|
+
const result = await runAgentWorkflow({ workflow, target: target([response]) });
|
|
188
|
+
expect(result.record.reason).toBe('usage_unavailable');
|
|
189
|
+
expect(result.record.budgets.toolCalls).toBe(0);
|
|
190
|
+
expect(result.record.usage.status).toBe('unavailable');
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
test('legacy zero usage remains unavailable, explicitly measured zero is reported', async () => {
|
|
194
|
+
const response = { ...answer(), tokens: { prompt: 0, completion: 0, total: 0 } };
|
|
195
|
+
expect(
|
|
196
|
+
(await runAgentWorkflow({ workflow: fixture(), target: target([response]) })).record.usage
|
|
197
|
+
.status
|
|
198
|
+
).toBe('unavailable');
|
|
199
|
+
expect(
|
|
200
|
+
(
|
|
201
|
+
await runAgentWorkflow({
|
|
202
|
+
workflow: fixture(),
|
|
203
|
+
target: target([{ ...response, usageAvailable: true }]),
|
|
204
|
+
})
|
|
205
|
+
).record.usage.status
|
|
206
|
+
).toBe('reported');
|
|
207
|
+
});
|
|
208
|
+
test.each([
|
|
209
|
+
['unknown tool', () => answer([call('send_email')])],
|
|
210
|
+
['coercion', () => answer([call('calculator', { operation: 'add', a: '1', b: 2 })])],
|
|
211
|
+
[
|
|
212
|
+
'malformed JSON',
|
|
213
|
+
() => {
|
|
214
|
+
const c = call();
|
|
215
|
+
c.function.arguments = '{';
|
|
216
|
+
return answer([c]);
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
['duplicate ID', () => answer([call(), call()])],
|
|
220
|
+
[
|
|
221
|
+
'unsafe keys',
|
|
222
|
+
() => {
|
|
223
|
+
const c = call();
|
|
224
|
+
c.function.arguments = '{"__proto__":{}}';
|
|
225
|
+
return answer([c]);
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
['extra response key', () => ({ ...answer(), raw: 'secret' })],
|
|
229
|
+
['missing usage', () => ({ ...answer(), tokens: undefined })],
|
|
230
|
+
['inconsistent usage', () => ({ ...answer(), tokens: { prompt: 2, completion: 1, total: 8 } })],
|
|
231
|
+
[
|
|
232
|
+
'getter',
|
|
233
|
+
() =>
|
|
234
|
+
Object.defineProperty(answer(), 'model', {
|
|
235
|
+
enumerable: true,
|
|
236
|
+
get() {
|
|
237
|
+
throw new Error('SECRET');
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
],
|
|
241
|
+
] as const)('rejects custom target %s', async (_, value) => {
|
|
242
|
+
const result = await runAgentWorkflow({
|
|
243
|
+
workflow: fixture(),
|
|
244
|
+
target: target(async () => value() as AgentTurnResult),
|
|
245
|
+
});
|
|
246
|
+
expect(result.record.execution).toBe('invalid');
|
|
247
|
+
expect(JSON.stringify(result.record)).not.toContain('SECRET');
|
|
248
|
+
for (const requested of result.record.events.filter((e) => e.type.endsWith('_requested')))
|
|
249
|
+
expect(
|
|
250
|
+
result.record.events.some(
|
|
251
|
+
(e) => e.operationId === requested.operationId && e.type.endsWith('_completed')
|
|
252
|
+
)
|
|
253
|
+
).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
test('rejects previously used call IDs on subsequent model turns', async () => {
|
|
256
|
+
const result = await runAgentWorkflow({
|
|
257
|
+
workflow: fixture(),
|
|
258
|
+
target: target([answer([call()]), answer([call()])]),
|
|
259
|
+
});
|
|
260
|
+
expect(result.record.reason).toBe('invalid_response');
|
|
261
|
+
expect(result.record.budgets.toolCalls).toBe(1);
|
|
262
|
+
});
|
|
263
|
+
test('host path grants deny before a custom environment gets a call', async () => {
|
|
264
|
+
const workflow = fixture();
|
|
265
|
+
workflow.environment.policy.paths = { read: ['note.txt'], write: [] };
|
|
266
|
+
let invoked = 0;
|
|
267
|
+
const factory: WorkflowEnvironmentFactory = async (options) => {
|
|
268
|
+
const env = await createSimulatedWorkflowEnvironment(options);
|
|
269
|
+
return {
|
|
270
|
+
...env,
|
|
271
|
+
execute: async (...args) => {
|
|
272
|
+
invoked++;
|
|
273
|
+
return env.execute(...args);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
const result = await runAgentWorkflow({
|
|
278
|
+
workflow,
|
|
279
|
+
target: target([
|
|
280
|
+
answer([call('write_file', { path: 'elsewhere.txt', content: 'forbidden' })]),
|
|
281
|
+
]),
|
|
282
|
+
environmentFactory: factory,
|
|
283
|
+
});
|
|
284
|
+
expect(invoked).toBe(0);
|
|
285
|
+
expect(result.record.policy).toBe('denied');
|
|
286
|
+
});
|
|
287
|
+
test('invalid policy and unavailable sandbox environment fail closed without fallback', async () => {
|
|
288
|
+
const workflow = fixture();
|
|
289
|
+
workflow.environment.type = 'sandbox';
|
|
290
|
+
expect(
|
|
291
|
+
(
|
|
292
|
+
await runAgentWorkflow({
|
|
293
|
+
workflow,
|
|
294
|
+
target: target([]),
|
|
295
|
+
environmentFactory: async () => {
|
|
296
|
+
throw new WorkflowEnvironmentInitializationError({
|
|
297
|
+
status: 'completed',
|
|
298
|
+
artifacts: 'discarded',
|
|
299
|
+
pendingOperations: 0,
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
).record.reason
|
|
304
|
+
).toBe('environment_unavailable');
|
|
305
|
+
(workflow.environment.policy as unknown as Record<string, unknown>).network = 'allowed';
|
|
306
|
+
const t = target([]);
|
|
307
|
+
expect((await runAgentWorkflow({ workflow, target: t })).record.reason).toBe(
|
|
308
|
+
'invalid_workflow'
|
|
309
|
+
);
|
|
310
|
+
expect(t.requests).toHaveLength(0);
|
|
311
|
+
});
|
|
312
|
+
test('validates custom environment outputs and failed snapshots', async () => {
|
|
313
|
+
const bad: WorkflowEnvironmentFactory = async (options) => ({
|
|
314
|
+
...(await createSimulatedWorkflowEnvironment(options)),
|
|
315
|
+
execute: async () => ({
|
|
316
|
+
status: 'succeeded',
|
|
317
|
+
output: { value: 'coerced' },
|
|
318
|
+
state: {},
|
|
319
|
+
evidence: { tool: 'calculator', version: '1', status: 'succeeded' },
|
|
320
|
+
}),
|
|
321
|
+
});
|
|
322
|
+
expect(
|
|
323
|
+
(
|
|
324
|
+
await runAgentWorkflow({
|
|
325
|
+
workflow: fixture(),
|
|
326
|
+
target: target([answer([call()])]),
|
|
327
|
+
environmentFactory: bad,
|
|
328
|
+
})
|
|
329
|
+
).record.reason
|
|
330
|
+
).toBe('invalid_environment');
|
|
331
|
+
const failing: WorkflowEnvironmentFactory = async (options) => ({
|
|
332
|
+
...(await createSimulatedWorkflowEnvironment(options)),
|
|
333
|
+
snapshot: async () => {
|
|
334
|
+
throw new Error('private');
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
const result = await runAgentWorkflow({
|
|
338
|
+
workflow: fixture(),
|
|
339
|
+
target: target([answer()]),
|
|
340
|
+
environmentFactory: failing,
|
|
341
|
+
});
|
|
342
|
+
expect(result.state).toBeNull();
|
|
343
|
+
expect(result.record.artifacts.state).toBe('unavailable');
|
|
344
|
+
});
|
|
345
|
+
test('preflightOnly performs correlated roundtrip and never initializes environment', async () => {
|
|
346
|
+
let initialized = false;
|
|
347
|
+
const workflow = fixture();
|
|
348
|
+
workflow.workflow.initial_state = 'missing-fixture.yaml';
|
|
349
|
+
const t = target(async (request, index) => {
|
|
350
|
+
const nonce = (
|
|
351
|
+
request.tools[0].function.parameters.properties as Record<string, { const: string }>
|
|
352
|
+
).nonce.const;
|
|
353
|
+
if (index === 0) return answer([call('artemis_probe', { nonce }, 'probe-1')]);
|
|
354
|
+
expect(request.messages.at(-1)?.toolCallId).toBe('probe-1');
|
|
355
|
+
return answer([], nonce);
|
|
356
|
+
});
|
|
357
|
+
const result = await runAgentWorkflow({
|
|
358
|
+
workflow,
|
|
359
|
+
target: t,
|
|
360
|
+
preflightOnly: true,
|
|
361
|
+
environmentFactory: async (options) => {
|
|
362
|
+
initialized = true;
|
|
363
|
+
return createSimulatedWorkflowEnvironment(options);
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
expect(initialized).toBe(false);
|
|
367
|
+
expect(result.record.capability.preflight).toBe('passed');
|
|
368
|
+
expect(result.record.usage.preflight.total).toBe(6);
|
|
369
|
+
expect(result.record.budgets.actions).toBe(3);
|
|
370
|
+
expect(result.transcript).toEqual([]);
|
|
371
|
+
});
|
|
372
|
+
test('preflight prose is unsupported and stops before scenario turns', async () => {
|
|
373
|
+
const t = target([answer([], '{"tool":"artemis_probe"}')]);
|
|
374
|
+
const result = await runAgentWorkflow({ workflow: fixture(), target: t, preflight: true });
|
|
375
|
+
expect(result.record.execution).toBe('unsupported');
|
|
376
|
+
expect(result.record.capability.preflight).toBe('failed');
|
|
377
|
+
expect(t.requests).toHaveLength(1);
|
|
378
|
+
});
|
|
379
|
+
test('deadline/cancel stop admission and pending tools cannot suppress close', async () => {
|
|
380
|
+
let closed = false;
|
|
381
|
+
let receivedAbort = false;
|
|
382
|
+
const factory: WorkflowEnvironmentFactory = async (options) => ({
|
|
383
|
+
...(await createSimulatedWorkflowEnvironment(options)),
|
|
384
|
+
execute: async (_, signal) => {
|
|
385
|
+
signal.addEventListener('abort', () => {
|
|
386
|
+
receivedAbort = true;
|
|
387
|
+
});
|
|
388
|
+
return never();
|
|
389
|
+
},
|
|
390
|
+
close: async () => {
|
|
391
|
+
closed = true;
|
|
392
|
+
return { status: 'completed', artifacts: 'discarded' };
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
const workflow = fixture();
|
|
396
|
+
workflow.environment.policy.budgets.timeout_ms = 20;
|
|
397
|
+
const result = await runAgentWorkflow({
|
|
398
|
+
workflow,
|
|
399
|
+
target: target([answer([call()])]),
|
|
400
|
+
environmentFactory: factory,
|
|
401
|
+
cleanupTimeoutMs: 30,
|
|
402
|
+
});
|
|
403
|
+
expect(result.record.execution).toBe('timeout');
|
|
404
|
+
expect(receivedAbort).toBe(true);
|
|
405
|
+
expect(closed).toBe(true);
|
|
406
|
+
expect(result.record.cleanup.status).toBe('unresolved');
|
|
407
|
+
expect(result.record.cleanup.pendingOperations).toBeGreaterThan(0);
|
|
408
|
+
expect(result.state).toBeNull();
|
|
409
|
+
});
|
|
410
|
+
test('failed environment initialization retains unresolved resource evidence', async () => {
|
|
411
|
+
const result = await runAgentWorkflow({
|
|
412
|
+
workflow: fixture(),
|
|
413
|
+
target: target([]),
|
|
414
|
+
environmentFactory: async () => {
|
|
415
|
+
throw new WorkflowEnvironmentInitializationError({
|
|
416
|
+
status: 'unresolved',
|
|
417
|
+
artifacts: 'unknown',
|
|
418
|
+
pendingOperations: 1,
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
expect(result.record.reason).toBe('environment_unavailable');
|
|
423
|
+
expect(result.record.cleanup).toEqual({
|
|
424
|
+
status: 'unresolved',
|
|
425
|
+
artifacts: 'unknown',
|
|
426
|
+
pendingOperations: 1,
|
|
427
|
+
});
|
|
428
|
+
expect(result.state).toBeNull();
|
|
429
|
+
});
|
|
430
|
+
test('environment that arrives after bounded cleanup still receives a close attempt', async () => {
|
|
431
|
+
const workflow = fixture();
|
|
432
|
+
workflow.environment.policy.budgets.timeout_ms = 10;
|
|
433
|
+
let release: () => void = () => {};
|
|
434
|
+
const delayed = new Promise<void>((resolve) => {
|
|
435
|
+
release = resolve;
|
|
436
|
+
});
|
|
437
|
+
let closed = false;
|
|
438
|
+
const result = await runAgentWorkflow({
|
|
439
|
+
workflow,
|
|
440
|
+
target: target([]),
|
|
441
|
+
cleanupTimeoutMs: 30,
|
|
442
|
+
environmentFactory: async (options) => {
|
|
443
|
+
const environment = await createSimulatedWorkflowEnvironment(options);
|
|
444
|
+
await delayed;
|
|
445
|
+
return {
|
|
446
|
+
...environment,
|
|
447
|
+
close: async (signal) => {
|
|
448
|
+
closed = true;
|
|
449
|
+
return environment.close(signal);
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
expect(result.record.cleanup.status).toBe('unresolved');
|
|
455
|
+
release();
|
|
456
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
457
|
+
expect(closed).toBe(true);
|
|
458
|
+
expect(result.record.cleanup.status).toBe('unresolved');
|
|
459
|
+
});
|
|
460
|
+
test('callbacks settling inside the drain window leave no synthetic pending operation', async () => {
|
|
461
|
+
const workflow = fixture();
|
|
462
|
+
workflow.environment.policy.budgets.timeout_ms = 20;
|
|
463
|
+
const result = await runAgentWorkflow({
|
|
464
|
+
workflow,
|
|
465
|
+
cleanupTimeoutMs: 300,
|
|
466
|
+
target: target(() => new Promise((resolve) => setTimeout(() => resolve(answer()), 50))),
|
|
467
|
+
});
|
|
468
|
+
expect(result.record.execution).toBe('timeout');
|
|
469
|
+
expect(result.record.cleanup.status).toBe('completed');
|
|
470
|
+
expect(result.record.cleanup.pendingOperations).toBe(0);
|
|
471
|
+
expect(result.record.usage.missingRequests).toBe(1);
|
|
472
|
+
});
|
|
473
|
+
test('initialization diagnostics never enter safe cleanup evidence', async () => {
|
|
474
|
+
const result = await runAgentWorkflow({
|
|
475
|
+
workflow: fixture(),
|
|
476
|
+
target: target([]),
|
|
477
|
+
environmentFactory: async () => {
|
|
478
|
+
const detail = {
|
|
479
|
+
status: 'unresolved' as const,
|
|
480
|
+
artifacts: 'unknown' as const,
|
|
481
|
+
pendingOperations: 1,
|
|
482
|
+
diagnostic: 'SYNTHETIC_PRIVATE_CONTENT',
|
|
483
|
+
};
|
|
484
|
+
throw new WorkflowEnvironmentInitializationError(detail);
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
expect(result.record.cleanup.status).toBe('unresolved');
|
|
488
|
+
expect(JSON.stringify(result.record)).not.toContain('SYNTHETIC_PRIVATE_CONTENT');
|
|
489
|
+
expect(Object.keys(result.record.cleanup).sort()).toEqual([
|
|
490
|
+
'artifacts',
|
|
491
|
+
'pendingOperations',
|
|
492
|
+
'status',
|
|
493
|
+
]);
|
|
494
|
+
});
|
|
495
|
+
test('non-enumerable target getters are rejected without execution', async () => {
|
|
496
|
+
let called = false;
|
|
497
|
+
const response = Object.defineProperty(answer(), 'model', {
|
|
498
|
+
enumerable: false,
|
|
499
|
+
get() {
|
|
500
|
+
called = true;
|
|
501
|
+
return 'fixture-model';
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
const result = await runAgentWorkflow({
|
|
505
|
+
workflow: fixture(),
|
|
506
|
+
target: target(async () => response),
|
|
507
|
+
});
|
|
508
|
+
expect(called).toBe(false);
|
|
509
|
+
expect(result.record.execution).toBe('invalid');
|
|
510
|
+
});
|
|
511
|
+
test('non-enumerable workflow getters are rejected without execution', async () => {
|
|
512
|
+
let called = false;
|
|
513
|
+
const workflow = fixture();
|
|
514
|
+
Object.defineProperty(workflow.target, 'model', {
|
|
515
|
+
enumerable: false,
|
|
516
|
+
get() {
|
|
517
|
+
called = true;
|
|
518
|
+
return 'fixture-model';
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
const result = await runAgentWorkflow({ workflow, target: target([]) });
|
|
522
|
+
expect(called).toBe(false);
|
|
523
|
+
expect(result.record.reason).toBe('invalid_workflow');
|
|
524
|
+
});
|
|
525
|
+
test('cancelled model usage remains partial even if callback drains', async () => {
|
|
526
|
+
const t = target(async (_, index, signal) => {
|
|
527
|
+
if (index === 0) return answer([call()]);
|
|
528
|
+
setTimeout(() => session.cancel(), 2);
|
|
529
|
+
return new Promise((resolve) =>
|
|
530
|
+
signal?.addEventListener('abort', () => resolve(answer()), { once: true })
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
const session = createAgentWorkflowSession({
|
|
534
|
+
workflow: fixture(),
|
|
535
|
+
target: t,
|
|
536
|
+
cleanupTimeoutMs: 30,
|
|
537
|
+
});
|
|
538
|
+
const result = await session.run();
|
|
539
|
+
expect(result.record.execution).toBe('cancelled');
|
|
540
|
+
expect(result.record.usage.status).toBe('partial');
|
|
541
|
+
expect(result.record.usage.missingRequests).toBe(1);
|
|
542
|
+
});
|
|
543
|
+
test('cancel before run causes no model/environment work', async () => {
|
|
544
|
+
const t = target([]);
|
|
545
|
+
const session = createAgentWorkflowSession({ workflow: fixture(), target: t });
|
|
546
|
+
session.cancel();
|
|
547
|
+
const result = await session.run();
|
|
548
|
+
expect(result.record.execution).toBe('cancelled');
|
|
549
|
+
expect(t.requests).toHaveLength(0);
|
|
550
|
+
});
|
|
551
|
+
test('tracks hidden legacy transport after bounded facade returns', async () => {
|
|
552
|
+
const workflow = fixture();
|
|
553
|
+
workflow.environment.policy.budgets.timeout_ms = 15;
|
|
554
|
+
const bridge = createModelClientTarget({
|
|
555
|
+
provider: 'custom',
|
|
556
|
+
capabilities: async () => ({
|
|
557
|
+
streaming: false,
|
|
558
|
+
functionCalling: true,
|
|
559
|
+
toolUse: true,
|
|
560
|
+
maxContext: 1000,
|
|
561
|
+
}),
|
|
562
|
+
generate: () => never(),
|
|
563
|
+
});
|
|
564
|
+
const result = await runAgentWorkflow({ workflow, target: bridge, cleanupTimeoutMs: 30 });
|
|
565
|
+
expect(result.record.execution).toBe('timeout');
|
|
566
|
+
expect(result.record.cleanup.status).toBe('unresolved');
|
|
567
|
+
expect(result.record.usage.inFlightUnknown).toBe(true);
|
|
568
|
+
expect(result.record.usage.missingRequests).toBe(1);
|
|
569
|
+
});
|
|
570
|
+
test('bounded metadata has no raw IDs, paths, content, arguments or errors', async () => {
|
|
571
|
+
const result = await runAgentWorkflow({
|
|
572
|
+
workflow: fixture(),
|
|
573
|
+
target: target([
|
|
574
|
+
answer([call('read_file', { path: 'note.txt' }, 'SECRET-CALL-ID')]),
|
|
575
|
+
answer([], 'PRIVATE-CONTENT'),
|
|
576
|
+
]),
|
|
577
|
+
});
|
|
578
|
+
const saved = JSON.stringify(result.record);
|
|
579
|
+
for (const value of ['SECRET-CALL-ID', 'note.txt', 'PRIVATE-CONTENT', 'Complete task.'])
|
|
580
|
+
expect(saved).not.toContain(value);
|
|
581
|
+
expect(
|
|
582
|
+
result.record.events.find((e) => e.type === 'tool_requested')?.requestedCallIdHash
|
|
583
|
+
).toHaveLength(64);
|
|
584
|
+
expect(result.record.configuration?.limits.max_actions).toBe(30);
|
|
585
|
+
});
|
|
586
|
+
test('trace truncation preserves terminal evidence and live stream finishes', async () => {
|
|
587
|
+
const workflow = fixture();
|
|
588
|
+
workflow.environment.policy.budgets.max_actions = 300;
|
|
589
|
+
workflow.environment.policy.budgets.timeout_ms = 5000;
|
|
590
|
+
const t = target(async (_, index) =>
|
|
591
|
+
index < 70
|
|
592
|
+
? answer([call('calculator', { operation: 'add', a: 1, b: 2 }, `call-${index}`)])
|
|
593
|
+
: answer()
|
|
594
|
+
);
|
|
595
|
+
const session = createAgentWorkflowSession({ workflow, target: t });
|
|
596
|
+
const events: unknown[] = [];
|
|
597
|
+
const collect = (async () => {
|
|
598
|
+
for await (const event of session.events()) events.push(event);
|
|
599
|
+
})();
|
|
600
|
+
const result = await session.run();
|
|
601
|
+
await collect;
|
|
602
|
+
expect(result.record.execution).toBe('completed');
|
|
603
|
+
expect(result.record.events).toHaveLength(256);
|
|
604
|
+
expect(result.record.events.at(-1)?.type).toBe('finished');
|
|
605
|
+
expect(result.record.droppedEvents).toBeGreaterThan(0);
|
|
606
|
+
expect(events).toHaveLength(256);
|
|
607
|
+
});
|
|
608
|
+
test('fixture loader supports bounded YAML/JSON and rejects secrets/symlinks/traversal', async () => {
|
|
609
|
+
const root = await mkdtemp(join(tmpdir(), 'workflow-fixture-'));
|
|
610
|
+
await mkdir(join(root, 'fixtures'));
|
|
611
|
+
await writeFile(
|
|
612
|
+
join(root, 'fixtures', 'state.yaml'),
|
|
613
|
+
'workflow_state:\n marker: "${SECRET_TOKEN}"\n'
|
|
614
|
+
);
|
|
615
|
+
const workflow = fixture();
|
|
616
|
+
workflow.workflow.initial_state = 'fixtures/state.yaml';
|
|
617
|
+
expect(await resolveWorkflowInitialState(workflow, root)).toEqual({
|
|
618
|
+
workflow_state: { marker: '${SECRET_TOKEN}' },
|
|
619
|
+
});
|
|
620
|
+
await symlink(join(root, 'fixtures', 'state.yaml'), join(root, 'fixtures', 'link.yaml'));
|
|
621
|
+
for (const path of ['fixtures/link.yaml', 'fixtures/secrets.json', '../state.yaml']) {
|
|
622
|
+
workflow.workflow.initial_state = path;
|
|
623
|
+
await expect(resolveWorkflowInitialState(workflow, root)).rejects.toThrow();
|
|
624
|
+
}
|
|
625
|
+
await writeFile(join(root, 'fixtures', 'state.yaml'), 'a: &x [1]\nb: *x\n');
|
|
626
|
+
workflow.workflow.initial_state = 'fixtures/state.yaml';
|
|
627
|
+
await expect(resolveWorkflowInitialState(workflow, root)).rejects.toThrow();
|
|
628
|
+
});
|
|
629
|
+
});
|