fix(invariants): assert runtime relationships, not API shapes
This commit is contained in:
@@ -1,34 +1,67 @@
|
||||
/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tools`. @module @deepseek-ai/dsh-tools/invariant */
|
||||
/** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { ToolExecution, ToolExecutionResult } from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tools-invariant'
|
||||
/** Services required before the companion can register. */
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Install checks for this package's active plugin fibers. */
|
||||
type ToolStage = 'pre' | 'execute' | 'post'
|
||||
|
||||
/** Validate the immutable final execution/result snapshot. */
|
||||
function validateResult(
|
||||
exec: Readonly<ToolExecution>,
|
||||
result: Readonly<ToolExecutionResult>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
if (!Object.isFrozen(exec)) fail('tools/result execution must be frozen before publication')
|
||||
if (!Object.isFrozen(result) || !Object.isFrozen(result.content)) {
|
||||
fail('tools/result outcome and content must be frozen before publication')
|
||||
}
|
||||
if (exec.name.length === 0 || String(exec.callId).length === 0) {
|
||||
fail('tools/result execution must carry non-empty name and callId')
|
||||
}
|
||||
}
|
||||
|
||||
/** Install monotonic pipeline and final-snapshot checks. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
observePluginInvariant(ctx, fail, {
|
||||
name: 'ToolRegistry',
|
||||
inject: [
|
||||
'systemPrompt',
|
||||
],
|
||||
effects: [
|
||||
'ctx.provide("tools")',
|
||||
'systemPrompt.tools()',
|
||||
],
|
||||
services: [
|
||||
'tools',
|
||||
],
|
||||
})
|
||||
const stages = new WeakMap<object, ToolStage>()
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName === 'tools/pre-execute') {
|
||||
const exec = args[0] as ToolExecution
|
||||
if (stages.has(exec)) fail('tools/pre-execute repeated for one execution')
|
||||
stages.set(exec, 'pre')
|
||||
return
|
||||
}
|
||||
if (eventName === 'tools/execute') {
|
||||
const exec = args[0] as ToolExecution
|
||||
if (stages.get(exec) !== 'pre') fail('tools/execute must follow tools/pre-execute')
|
||||
stages.set(exec, 'execute')
|
||||
return
|
||||
}
|
||||
if (eventName === 'tools/post-execute') {
|
||||
const exec = args[0] as ToolExecution
|
||||
const previous = stages.get(exec)
|
||||
if (previous !== 'pre' && previous !== 'execute') {
|
||||
fail('tools/post-execute must follow tools/pre-execute or tools/execute')
|
||||
}
|
||||
stages.set(exec, 'post')
|
||||
return
|
||||
}
|
||||
if (eventName !== 'tools/result') return
|
||||
const [exec, result] = args as [Readonly<ToolExecution>, Readonly<ToolExecutionResult>]
|
||||
validateResult(exec, result, fail)
|
||||
stages.delete(exec)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* Register the tools invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
|
||||
84
packages/core/tools/tests/invariant.spec.ts
Normal file
84
packages/core/tools/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ToolsInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
|
||||
token: Symbol('tool') as ToolExecutionToken,
|
||||
callId: CallId('call-1'),
|
||||
name: 'echo',
|
||||
arguments: Object.freeze({ text: 'hi' }),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const outcome = (): ToolExecutionResult => Object.freeze({
|
||||
content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void {
|
||||
ctx.emit(scopeTarget(ctx as never, undefined), 'tools/result', exec, result)
|
||||
}
|
||||
|
||||
async function stage(ctx: Context, name: 'tools/pre-execute' | 'tools/execute', exec: ToolExecution): Promise<void> {
|
||||
if (name === 'tools/pre-execute') {
|
||||
await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve({ kind: 'allow' as const }))
|
||||
} else {
|
||||
await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve(outcome()))
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool-pipeline invariants', () => {
|
||||
it('accepts dispatch and denial stage orders with frozen results', async () => {
|
||||
const ctx = await setup()
|
||||
const dispatched = execution()
|
||||
await stage(ctx, 'tools/pre-execute', dispatched)
|
||||
await stage(ctx, 'tools/execute', dispatched)
|
||||
await ctx.waterfall(ctx as never, 'tools/post-execute', dispatched, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
|
||||
Object.freeze(dispatched)
|
||||
emitResult(ctx, dispatched, outcome())
|
||||
|
||||
const denied = execution({ callId: CallId('call-2') })
|
||||
await stage(ctx, 'tools/pre-execute', denied)
|
||||
await ctx.waterfall(ctx as never, 'tools/post-execute', denied, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
|
||||
Object.freeze(denied)
|
||||
emitResult(ctx, denied, outcome())
|
||||
ctx.emit('tools/change')
|
||||
})
|
||||
|
||||
it('rejects repeated and out-of-order pipeline stages', async () => {
|
||||
const ctx = await setup()
|
||||
const exec = execution()
|
||||
await stage(ctx, 'tools/pre-execute', exec)
|
||||
await expect(stage(ctx, 'tools/pre-execute', exec)).rejects.toThrow(/repeated/)
|
||||
|
||||
const noPre = execution({ callId: CallId('call-2') })
|
||||
await expect(stage(ctx, 'tools/execute', noPre)).rejects.toThrow(/must follow tools\/pre-execute/)
|
||||
expect(() => ctx.waterfall(
|
||||
ctx as never, 'tools/post-execute', noPre, outcome(),
|
||||
() => Promise.resolve({ kind: 'accept' as const }),
|
||||
)).toThrow(/must follow tools\/pre-execute or tools\/execute/)
|
||||
})
|
||||
|
||||
it('rejects mutable or anonymous final snapshots', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/)
|
||||
|
||||
const exec = Object.freeze(execution())
|
||||
expect(() => { emitResult(ctx, exec, { content: [], isError: false }) })
|
||||
.toThrow(/outcome and content must be frozen/)
|
||||
|
||||
const anonymous = Object.freeze(execution({ name: '' }))
|
||||
expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user