@nifrajs/testing 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/fault-profile.d.ts +47 -0
- package/dist/fault-profile.d.ts.map +1 -0
- package/dist/fault-profile.js +107 -0
- package/dist/fault-profile.js.map +1 -0
- package/dist/idempotency.d.ts +18 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +59 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/tool-contract.d.ts +8 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +156 -0
- package/dist/tool-contract.js.map +1 -0
- package/dist/trajectory.d.ts +62 -0
- package/dist/trajectory.d.ts.map +1 -0
- package/dist/trajectory.js +200 -0
- package/dist/trajectory.js.map +1 -0
- package/package.json +11 -7
- package/src/fault-profile.ts +174 -0
- package/src/idempotency.ts +89 -0
- package/src/index.ts +41 -0
- package/src/tool-contract.ts +189 -0
- package/src/trajectory.ts +328 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/** Test adapters for typed tool contracts. Assertions stay on the shared core pipeline. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createToolHttpHandler,
|
|
5
|
+
executeTool,
|
|
6
|
+
type ToolAdapter,
|
|
7
|
+
type ToolAdapterResult,
|
|
8
|
+
type ToolCallOptions,
|
|
9
|
+
type ToolContract,
|
|
10
|
+
type ToolError,
|
|
11
|
+
type ToolEvidence,
|
|
12
|
+
} from "@nifrajs/core/tool-contract"
|
|
13
|
+
import { toMcpTool } from "@nifrajs/mcp/tool-contract"
|
|
14
|
+
|
|
15
|
+
export function inProcessToolAdapter<Input, Output>(
|
|
16
|
+
tool: ToolContract<Input, Output>,
|
|
17
|
+
baseOptions: ToolCallOptions = {},
|
|
18
|
+
): ToolAdapter {
|
|
19
|
+
return {
|
|
20
|
+
name: "in-process",
|
|
21
|
+
call: (input, options) => executeTool(tool, input, { ...baseOptions, ...options }),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function testToolAdapter<Input, Output>(
|
|
26
|
+
tool: ToolContract<Input, Output>,
|
|
27
|
+
baseOptions: ToolCallOptions = {},
|
|
28
|
+
): ToolAdapter {
|
|
29
|
+
return {
|
|
30
|
+
name: "test",
|
|
31
|
+
call: (input, options) => executeTool(tool, input, { ...baseOptions, ...options }),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mcpToolAdapter<Input, Output>(
|
|
36
|
+
tool: ToolContract<Input, Output>,
|
|
37
|
+
baseOptions: Omit<ToolCallOptions, "signal" | "ledger"> = {},
|
|
38
|
+
): ToolAdapter {
|
|
39
|
+
return {
|
|
40
|
+
name: "mcp",
|
|
41
|
+
async call(input, options) {
|
|
42
|
+
const args = recordOf(input)
|
|
43
|
+
if (args === undefined) throw new Error("tool adapter: MCP input must be an object")
|
|
44
|
+
const mcp = toMcpTool(tool, { ...baseOptions, ...options })
|
|
45
|
+
const result = await mcp.handler(args, {
|
|
46
|
+
signal: options?.signal ?? new AbortController().signal,
|
|
47
|
+
requestId: "test",
|
|
48
|
+
reportProgress: () => {},
|
|
49
|
+
})
|
|
50
|
+
if (typeof result === "string") throw new Error("tool adapter: MCP returned plain text")
|
|
51
|
+
const text = result.content?.find((item) => item.type === "text")
|
|
52
|
+
if (text === undefined || text.type !== "text")
|
|
53
|
+
throw new Error("tool adapter: MCP result has no text")
|
|
54
|
+
const parsed = JSON.parse(text.text)
|
|
55
|
+
const record = recordOf(parsed)
|
|
56
|
+
if (result.isError === true) {
|
|
57
|
+
const error = toolErrorOf(record?.error)
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
dryRun: record?.dryRun === true,
|
|
61
|
+
error,
|
|
62
|
+
evidence: evidenceOf(record?.evidence),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
dryRun: record?.dryRun === true,
|
|
68
|
+
...(record?.output === undefined ? {} : { output: record.output }),
|
|
69
|
+
evidence: evidenceOf(record?.evidence),
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Exercise the Web adapter while returning the same normalized result shape as direct calls. */
|
|
76
|
+
export function httpToolAdapter<Input, Output>(
|
|
77
|
+
tool: ToolContract<Input, Output>,
|
|
78
|
+
baseOptions: Omit<ToolCallOptions, "signal" | "ledger"> = {},
|
|
79
|
+
): ToolAdapter {
|
|
80
|
+
return {
|
|
81
|
+
name: "http",
|
|
82
|
+
async call(input, options) {
|
|
83
|
+
const handler = createToolHttpHandler(tool, { ...baseOptions, ...options })
|
|
84
|
+
const request = new Request("http://nifra.local/tool", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: JSON.stringify(input),
|
|
87
|
+
})
|
|
88
|
+
const response = await handler(request)
|
|
89
|
+
const body = (await response.json()) as unknown
|
|
90
|
+
if (response.status >= 200 && response.status < 300) {
|
|
91
|
+
const record = recordOf(body)
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
dryRun: record?.dryRun === true,
|
|
95
|
+
...(record?.output === undefined ? {} : { output: record.output }),
|
|
96
|
+
evidence: Array.isArray(record?.evidence) ? record.evidence : [],
|
|
97
|
+
} satisfies ToolAdapterResult
|
|
98
|
+
}
|
|
99
|
+
const record = recordOf(body)
|
|
100
|
+
const error = toolErrorOf(record?.error)
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
dryRun: record?.dryRun === true,
|
|
104
|
+
error,
|
|
105
|
+
evidence: Array.isArray(record?.evidence) ? record.evidence : [],
|
|
106
|
+
} satisfies ToolAdapterResult
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
112
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
113
|
+
? (value as Record<string, unknown>)
|
|
114
|
+
: undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toolErrorOf(value: unknown): ToolError {
|
|
118
|
+
const record = recordOf(value)
|
|
119
|
+
const code = record?.code
|
|
120
|
+
const stage = record?.stage
|
|
121
|
+
if (!isToolErrorCode(code) || !isToolEvidenceStage(stage)) {
|
|
122
|
+
throw new Error("tool adapter: malformed error result")
|
|
123
|
+
}
|
|
124
|
+
return { code, stage }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function evidenceOf(value: unknown): readonly ToolEvidence[] {
|
|
128
|
+
if (!Array.isArray(value)) return []
|
|
129
|
+
const evidence: ToolEvidence[] = []
|
|
130
|
+
for (const item of value) {
|
|
131
|
+
const record = recordOf(item)
|
|
132
|
+
if (record === undefined || typeof record.seq !== "number" || !Number.isSafeInteger(record.seq))
|
|
133
|
+
continue
|
|
134
|
+
if (!isToolEvidenceStage(record.stage) || !isToolEvidenceOutcome(record.outcome)) continue
|
|
135
|
+
evidence.push({
|
|
136
|
+
seq: record.seq,
|
|
137
|
+
stage: record.stage,
|
|
138
|
+
outcome: record.outcome,
|
|
139
|
+
...(typeof record.code === "string" ? { code: record.code } : {}),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
return evidence
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isToolErrorCode(value: unknown): value is ToolError["code"] {
|
|
146
|
+
return (
|
|
147
|
+
typeof value === "string" &&
|
|
148
|
+
[
|
|
149
|
+
"input_invalid",
|
|
150
|
+
"capability_denied",
|
|
151
|
+
"execution_policy_unsatisfied",
|
|
152
|
+
"approval_required",
|
|
153
|
+
"approval_denied",
|
|
154
|
+
"idempotency_store_missing",
|
|
155
|
+
"idempotency_durability",
|
|
156
|
+
"idempotency_duplicate",
|
|
157
|
+
"idempotency_in_flight",
|
|
158
|
+
"idempotency_capacity",
|
|
159
|
+
"budget_exceeded",
|
|
160
|
+
"cancelled",
|
|
161
|
+
"execution_failed",
|
|
162
|
+
"output_invalid",
|
|
163
|
+
"ledger_failed",
|
|
164
|
+
].includes(value)
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isToolEvidenceStage(value: unknown): value is ToolEvidence["stage"] {
|
|
169
|
+
return (
|
|
170
|
+
typeof value === "string" &&
|
|
171
|
+
[
|
|
172
|
+
"input",
|
|
173
|
+
"capability",
|
|
174
|
+
"policy",
|
|
175
|
+
"approval",
|
|
176
|
+
"idempotency",
|
|
177
|
+
"budget",
|
|
178
|
+
"execution",
|
|
179
|
+
"output",
|
|
180
|
+
].includes(value)
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isToolEvidenceOutcome(value: unknown): value is ToolEvidence["outcome"] {
|
|
185
|
+
return (
|
|
186
|
+
typeof value === "string" &&
|
|
187
|
+
["passed", "denied", "failed", "skipped", "committed", "dry-run"].includes(value)
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentDefinition,
|
|
3
|
+
type AgentModelPort,
|
|
4
|
+
type AgentPorts,
|
|
5
|
+
type AgentRunResult,
|
|
6
|
+
type AgentTranscript,
|
|
7
|
+
type AgentTurnInput,
|
|
8
|
+
type AgentTurnState,
|
|
9
|
+
runAgent,
|
|
10
|
+
} from "@nifrajs/agent"
|
|
11
|
+
import type { StandardSchemaV1 } from "@nifrajs/core/schema"
|
|
12
|
+
import { createToolBudget, type ToolBudget, type ToolContract } from "@nifrajs/core/tool-contract"
|
|
13
|
+
import { createFailureLab, type FailureEvidence } from "./failure-lab.ts"
|
|
14
|
+
import type { FaultProfile, FaultProfileFault, FaultProfileFaultKind } from "./fault-profile.ts"
|
|
15
|
+
|
|
16
|
+
export interface TrajectoryTranscript {
|
|
17
|
+
readonly version: 1
|
|
18
|
+
readonly digest: string
|
|
19
|
+
readonly turn: AgentTranscript
|
|
20
|
+
readonly faultProfile?: string
|
|
21
|
+
readonly seed?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CreateTrajectoryTranscriptOptions {
|
|
25
|
+
readonly faultProfile?: string
|
|
26
|
+
readonly seed?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TrajectoryRun<Output> {
|
|
30
|
+
readonly result: AgentRunResult<Output>
|
|
31
|
+
readonly transcript: TrajectoryTranscript
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReplayTrajectoryOptions {
|
|
35
|
+
readonly state: AgentTurnState
|
|
36
|
+
readonly maxTurns?: number
|
|
37
|
+
readonly goal?: (output: unknown) => boolean
|
|
38
|
+
readonly faultProfile?: FaultProfile
|
|
39
|
+
readonly seed?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type TrajectoryInvariantId =
|
|
43
|
+
| "ledger-evidence"
|
|
44
|
+
| "capability-admission"
|
|
45
|
+
| "budget-monotonic"
|
|
46
|
+
| "bounded-stop"
|
|
47
|
+
| "resumable-suspension"
|
|
48
|
+
|
|
49
|
+
export interface TrajectoryInvariantResult {
|
|
50
|
+
readonly id: TrajectoryInvariantId
|
|
51
|
+
readonly ok: boolean
|
|
52
|
+
readonly code?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TrajectoryReplayResult<Output> {
|
|
56
|
+
readonly result: AgentRunResult<Output>
|
|
57
|
+
readonly transcript: TrajectoryTranscript
|
|
58
|
+
readonly faults: readonly FailureEvidence[]
|
|
59
|
+
readonly invariants: readonly TrajectoryInvariantResult[]
|
|
60
|
+
readonly regressionIds: readonly string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TrajectoryInvariantOptions {
|
|
64
|
+
readonly maxSteps?: number
|
|
65
|
+
/** Remaining budget snapshots, used when the caller owns a budget adapter. */
|
|
66
|
+
readonly budgetSnapshots?: readonly Readonly<Record<string, number>>[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_SEED = 0x4e_49_46_52
|
|
70
|
+
const FAULT_KINDS: readonly FaultProfileFaultKind[] = [
|
|
71
|
+
"tool-error",
|
|
72
|
+
"malformed-model-output",
|
|
73
|
+
"budget-pressure",
|
|
74
|
+
"approval-denial",
|
|
75
|
+
"cancellation",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
/** Record the transcript emitted by a turn without adding a second execution recording path. */
|
|
79
|
+
export async function createTrajectoryTranscript(
|
|
80
|
+
transcript: AgentTranscript,
|
|
81
|
+
options: CreateTrajectoryTranscriptOptions = {},
|
|
82
|
+
): Promise<TrajectoryTranscript> {
|
|
83
|
+
validateAgentTranscript(transcript)
|
|
84
|
+
const payload = {
|
|
85
|
+
version: 1,
|
|
86
|
+
turn: transcript,
|
|
87
|
+
...(options.faultProfile === undefined ? {} : { faultProfile: options.faultProfile }),
|
|
88
|
+
...(options.seed === undefined ? {} : { seed: options.seed }),
|
|
89
|
+
}
|
|
90
|
+
return Object.freeze({ ...payload, version: 1 as const, digest: await digest(payload) })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Alias that reads naturally at a call site which has just completed a run. */
|
|
94
|
+
export const recordTrajectory = createTrajectoryTranscript
|
|
95
|
+
|
|
96
|
+
export async function runTrajectory<
|
|
97
|
+
InputSchema extends StandardSchemaV1,
|
|
98
|
+
OutputSchema extends StandardSchemaV1,
|
|
99
|
+
>(
|
|
100
|
+
definition: AgentDefinition<InputSchema, OutputSchema>,
|
|
101
|
+
input: AgentTurnInput,
|
|
102
|
+
ports: AgentPorts,
|
|
103
|
+
options: {
|
|
104
|
+
readonly state: AgentTurnState
|
|
105
|
+
readonly maxTurns?: number
|
|
106
|
+
readonly goal?: (output: unknown) => boolean
|
|
107
|
+
},
|
|
108
|
+
): Promise<TrajectoryRun<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
|
|
109
|
+
const result = await runAgent(definition, input, ports, options)
|
|
110
|
+
const transcript = await createTrajectoryTranscript(result.transcript)
|
|
111
|
+
return Object.freeze({ result, transcript })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Replay a recorded run with local model decisions and dry-run tools. The supplied model port is
|
|
116
|
+
* never called. Fault schedules use the existing deterministic failure-lab controller.
|
|
117
|
+
*/
|
|
118
|
+
export async function replayTrajectory<
|
|
119
|
+
InputSchema extends StandardSchemaV1,
|
|
120
|
+
OutputSchema extends StandardSchemaV1,
|
|
121
|
+
>(
|
|
122
|
+
definition: AgentDefinition<InputSchema, OutputSchema>,
|
|
123
|
+
input: AgentTurnInput,
|
|
124
|
+
ports: Omit<AgentPorts, "model">,
|
|
125
|
+
transcript: TrajectoryTranscript,
|
|
126
|
+
options: ReplayTrajectoryOptions,
|
|
127
|
+
): Promise<TrajectoryReplayResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
|
|
128
|
+
validateAgentTranscript(transcript.turn)
|
|
129
|
+
const profile = options.faultProfile
|
|
130
|
+
const seed = options.seed ?? DEFAULT_SEED
|
|
131
|
+
const lab = createFailureLab({ seed, schedule: profile?.schedule ?? [] })
|
|
132
|
+
const faults = profile?.faults ?? []
|
|
133
|
+
const counts = new Map<string, number>()
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
if (ports.signal?.aborted === true) controller.abort()
|
|
136
|
+
ports.signal?.addEventListener("abort", () => controller.abort(), { once: true })
|
|
137
|
+
let responseIndex = 0
|
|
138
|
+
let idSequence = 0
|
|
139
|
+
const takeFault = (
|
|
140
|
+
point: FaultProfileFault["point"],
|
|
141
|
+
kind?: FaultProfileFaultKind,
|
|
142
|
+
): FaultProfileFault | undefined => {
|
|
143
|
+
for (const candidateKind of kind === undefined ? FAULT_KINDS : [kind]) {
|
|
144
|
+
const key = `${point}:${candidateKind}`
|
|
145
|
+
const occurrence = (counts.get(key) ?? 0) + 1
|
|
146
|
+
counts.set(key, occurrence)
|
|
147
|
+
const fault = faults.find(
|
|
148
|
+
(candidate) =>
|
|
149
|
+
candidate.point === point &&
|
|
150
|
+
candidate.kind === candidateKind &&
|
|
151
|
+
(candidate.occurrence ?? 1) === occurrence,
|
|
152
|
+
)
|
|
153
|
+
if (fault !== undefined) return fault
|
|
154
|
+
}
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
const model: AgentModelPort = {
|
|
158
|
+
complete: async (): Promise<unknown> => {
|
|
159
|
+
lab.checkpoint("trajectory.model")
|
|
160
|
+
const fault = takeFault("model")
|
|
161
|
+
if (fault?.kind === "malformed-model-output") return { kind: "invalid" }
|
|
162
|
+
if (fault?.kind === "cancellation") {
|
|
163
|
+
controller.abort()
|
|
164
|
+
throw new Error("trajectory cancellation")
|
|
165
|
+
}
|
|
166
|
+
if (fault?.kind === "budget-pressure") throw new Error("trajectory budget pressure")
|
|
167
|
+
const response = transcript.turn.responses[responseIndex++]
|
|
168
|
+
if (response === undefined) throw new Error("trajectory transcript ended")
|
|
169
|
+
return lab.provider("trajectory.model", () => response)
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
const tools = definition.tools.map((tool) => wrapTool(tool, lab, () => takeFault("tool")))
|
|
173
|
+
const replayDefinition: AgentDefinition<InputSchema, OutputSchema> = { ...definition, tools }
|
|
174
|
+
let effectiveApproval = ports.approval
|
|
175
|
+
if (effectiveApproval !== undefined) {
|
|
176
|
+
const approval = effectiveApproval
|
|
177
|
+
effectiveApproval = {
|
|
178
|
+
request: async (request) => {
|
|
179
|
+
const fault = takeFault("effect", "approval-denial")
|
|
180
|
+
if (fault !== undefined) return { status: "denied", reason: "trajectory fault" }
|
|
181
|
+
return approval.request(request)
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const pressuredBudget: ToolBudget = createToolBudget({ limits: { calls: 0 } })
|
|
186
|
+
const effectivePorts: AgentPorts = {
|
|
187
|
+
...ports,
|
|
188
|
+
model,
|
|
189
|
+
dryRun: true,
|
|
190
|
+
signal: controller.signal,
|
|
191
|
+
idFactory: () => `trajectory-${seed}-${++idSequence}`,
|
|
192
|
+
...(effectiveApproval === undefined ? {} : { approval: effectiveApproval }),
|
|
193
|
+
...(faults.some((fault) => fault.kind === "budget-pressure")
|
|
194
|
+
? { budget: pressuredBudget }
|
|
195
|
+
: {}),
|
|
196
|
+
}
|
|
197
|
+
const result = await runAgent(replayDefinition, input, effectivePorts, {
|
|
198
|
+
state: options.state,
|
|
199
|
+
...(options.maxTurns === undefined ? {} : { maxTurns: options.maxTurns }),
|
|
200
|
+
...(options.goal === undefined ? {} : { goal: options.goal }),
|
|
201
|
+
})
|
|
202
|
+
const invariantResults = checkTrajectoryInvariants(result)
|
|
203
|
+
const regressionIds = await Promise.all(
|
|
204
|
+
invariantResults.map((invariant) =>
|
|
205
|
+
trajectoryRegressionId(transcript, profile?.name ?? "none", invariant.id),
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
return Object.freeze({
|
|
209
|
+
result,
|
|
210
|
+
transcript,
|
|
211
|
+
faults: lab.evidence(),
|
|
212
|
+
invariants: Object.freeze(invariantResults),
|
|
213
|
+
regressionIds: Object.freeze(regressionIds),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function checkTrajectoryInvariants(
|
|
218
|
+
result: AgentRunResult<unknown>,
|
|
219
|
+
options: TrajectoryInvariantOptions = {},
|
|
220
|
+
): readonly TrajectoryInvariantResult[] {
|
|
221
|
+
const evidence = result.evidence
|
|
222
|
+
const committedTools = evidence.filter(
|
|
223
|
+
(item) => item.kind === "tool" && item.outcome === "committed",
|
|
224
|
+
)
|
|
225
|
+
const ledger = committedTools.every(
|
|
226
|
+
(item) => typeof item.ledgerHead === "string" && item.ledgerHead.length > 0,
|
|
227
|
+
)
|
|
228
|
+
const deniedEffects = new Set(
|
|
229
|
+
evidence
|
|
230
|
+
.filter((item) => item.code === "capability_denied" && item.effectId !== undefined)
|
|
231
|
+
.map((item) => item.effectId),
|
|
232
|
+
)
|
|
233
|
+
const capability = committedTools.every(
|
|
234
|
+
(item) => item.effectId === undefined || !deniedEffects.has(item.effectId),
|
|
235
|
+
)
|
|
236
|
+
const bounded =
|
|
237
|
+
evidence.length <= (options.maxSteps ?? 256) &&
|
|
238
|
+
(result.status === "completed" || result.status === "suspended")
|
|
239
|
+
const resumable =
|
|
240
|
+
result.status !== "suspended" ||
|
|
241
|
+
(result.state.status === "suspended" && result.pending.effectId.length > 0)
|
|
242
|
+
const snapshots = options.budgetSnapshots ?? []
|
|
243
|
+
const budget = snapshots.every((snapshot, index) => {
|
|
244
|
+
const values = Object.values(snapshot)
|
|
245
|
+
if (values.some((value) => !Number.isFinite(value) || value < 0)) return false
|
|
246
|
+
if (index === 0) return true
|
|
247
|
+
const previous = snapshots[index - 1] ?? {}
|
|
248
|
+
return Object.entries(snapshot).every(([axis, value]) => value <= (previous[axis] ?? value))
|
|
249
|
+
})
|
|
250
|
+
return Object.freeze([
|
|
251
|
+
{ id: "ledger-evidence", ok: ledger, ...(ledger ? {} : { code: "effect_without_ledger" }) },
|
|
252
|
+
{
|
|
253
|
+
id: "capability-admission",
|
|
254
|
+
ok: capability,
|
|
255
|
+
...(capability ? {} : { code: "capability_admission_failed" }),
|
|
256
|
+
},
|
|
257
|
+
{ id: "budget-monotonic", ok: budget, ...(budget ? {} : { code: "budget_negative" }) },
|
|
258
|
+
{ id: "bounded-stop", ok: bounded, ...(bounded ? {} : { code: "stop_bound_exceeded" }) },
|
|
259
|
+
{
|
|
260
|
+
id: "resumable-suspension",
|
|
261
|
+
ok: resumable,
|
|
262
|
+
...(resumable ? {} : { code: "suspension_not_resumable" }),
|
|
263
|
+
},
|
|
264
|
+
])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function assertTrajectoryInvariants(
|
|
268
|
+
result: AgentRunResult<unknown>,
|
|
269
|
+
options: TrajectoryInvariantOptions = {},
|
|
270
|
+
): void {
|
|
271
|
+
const failures = checkTrajectoryInvariants(result, options).filter((invariant) => !invariant.ok)
|
|
272
|
+
if (failures.length > 0)
|
|
273
|
+
throw new Error(
|
|
274
|
+
`trajectory invariants failed: ${failures.map((invariant) => invariant.code ?? invariant.id).join(",")}`,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function trajectoryRegressionId(
|
|
279
|
+
transcript: TrajectoryTranscript,
|
|
280
|
+
faultProfile: string,
|
|
281
|
+
invariant: TrajectoryInvariantId,
|
|
282
|
+
): Promise<string> {
|
|
283
|
+
if (faultProfile.trim() === "") throw new TypeError("trajectory: fault profile name is required")
|
|
284
|
+
return digest({ transcript: transcript.digest, faultProfile, invariant })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function wrapTool<Input, Output>(
|
|
288
|
+
tool: ToolContract<Input, Output>,
|
|
289
|
+
lab: ReturnType<typeof createFailureLab>,
|
|
290
|
+
nextFault: () => FaultProfileFault | undefined,
|
|
291
|
+
): ToolContract<Input, Output> {
|
|
292
|
+
return Object.freeze({
|
|
293
|
+
...tool,
|
|
294
|
+
execute: async (
|
|
295
|
+
input: Input,
|
|
296
|
+
context: Parameters<ToolContract<Input, Output>["execute"]>[1],
|
|
297
|
+
) => {
|
|
298
|
+
lab.checkpoint(`trajectory.tool.${tool.name}`)
|
|
299
|
+
const fault = nextFault()
|
|
300
|
+
if (fault?.kind === "tool-error" || fault?.kind === "budget-pressure")
|
|
301
|
+
throw new Error("trajectory tool fault")
|
|
302
|
+
if (fault?.kind === "cancellation") throw new Error("trajectory cancellation")
|
|
303
|
+
lab.checkpoint(`trajectory.effect.${tool.name}`)
|
|
304
|
+
return tool.execute(input, context)
|
|
305
|
+
},
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validateAgentTranscript(value: AgentTranscript): void {
|
|
310
|
+
if (
|
|
311
|
+
value === null ||
|
|
312
|
+
typeof value !== "object" ||
|
|
313
|
+
value.version !== 1 ||
|
|
314
|
+
typeof value.turnId !== "string" ||
|
|
315
|
+
!Array.isArray(value.responses) ||
|
|
316
|
+
!Array.isArray(value.evidence)
|
|
317
|
+
) {
|
|
318
|
+
throw new TypeError("trajectory: invalid agent transcript")
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function digest(value: unknown): Promise<string> {
|
|
323
|
+
const serialized = JSON.stringify(value)
|
|
324
|
+
if (serialized === undefined) throw new TypeError("trajectory: value is not serializable")
|
|
325
|
+
const bytes = new TextEncoder().encode(serialized)
|
|
326
|
+
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
|
|
327
|
+
return [...hash].map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
328
|
+
}
|