feat: add canonical typed tool outputs
This commit is contained in:
@@ -10,6 +10,8 @@ The self-referential cordis toolset: three model-facing tools over the live runt
|
||||
|
||||
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
|
||||
|
||||
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`.
|
||||
|
||||
## Trust stance
|
||||
|
||||
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
@@ -1450,7 +1450,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMap',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n message: string;\n info?: {\n name: string;\n code: string;\n };\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n content: Content /* …truncated — full shape in source */',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventReadRequest',
|
||||
@@ -1674,20 +1674,20 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolDefinition',
|
||||
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
|
||||
declaration: 'export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolErrorInfo',
|
||||
declaration: 'export interface ToolErrorInfo {\n name: string;\n code: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecuteReturn',
|
||||
declaration: 'export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n};',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecution',
|
||||
declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionFailure',
|
||||
declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionInput',
|
||||
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}',
|
||||
@@ -1698,16 +1698,28 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionResult',
|
||||
declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}',
|
||||
declaration: 'export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionSuccess',
|
||||
declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionToken',
|
||||
declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};',
|
||||
},
|
||||
{
|
||||
name: 'ToolFailure',
|
||||
declaration: 'export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolGuard',
|
||||
declaration: 'export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;',
|
||||
},
|
||||
{
|
||||
name: 'ToolOutputDefinition',
|
||||
declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolProviderResult',
|
||||
declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}',
|
||||
@@ -1718,7 +1730,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolResult',
|
||||
declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n}',
|
||||
declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolResultBlock',
|
||||
|
||||
@@ -21,11 +21,11 @@ export const FiberState = {
|
||||
export type FiberState = FiberStateEnum
|
||||
|
||||
/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
|
||||
export const STATE_LABELS: Record<FiberState, string> = {
|
||||
export const STATE_LABELS = {
|
||||
[FiberState.PENDING]: 'pending',
|
||||
[FiberState.LOADING]: 'loading',
|
||||
[FiberState.ACTIVE]: 'active',
|
||||
[FiberState.FAILED]: 'failed',
|
||||
[FiberState.DISPOSED]: 'disposed',
|
||||
[FiberState.UNLOADING]: 'unloading',
|
||||
}
|
||||
} as const satisfies Record<FiberState, string>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* return values with. The façade is a whitelist of lifecycle-safe verbs and declared services;
|
||||
* framework internals and context-valued service returns are denied.
|
||||
*
|
||||
* VM-realm schemas are rebuilt as host objects, and tool results are JSON-round-tripped and
|
||||
* shape-checked before session logging. Common JSON-Schema spellings are normalized when they
|
||||
* VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and
|
||||
* presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they
|
||||
* have one meaning; invalid vocabulary fails during registration with a teaching error.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/guard
|
||||
*/
|
||||
@@ -16,7 +16,9 @@ import { Context } from 'cordis'
|
||||
import type { Plugin } from 'cordis'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
|
||||
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
|
||||
@@ -254,34 +256,23 @@ const RETURN_PREVIEW_LIMIT = 120
|
||||
* (`String(…)` for the un-stringifiable undefined case), truncated to
|
||||
* {@link RETURN_PREVIEW_LIMIT}.
|
||||
*/
|
||||
function describeReturn(value: unknown): string {
|
||||
// JSON.stringify is TYPED as always returning string, but it yields
|
||||
// undefined for an undefined input (the routed forgot-return case) — the
|
||||
// assertion widens the type back to the runtime truth.
|
||||
const json = JSON.stringify(value) as string | undefined
|
||||
if (json === undefined) return String(value)
|
||||
function describeReturn(value: JsonValue): string {
|
||||
// The caller has already crossed cloneJson, so this value is lossless JSON
|
||||
// and serialization cannot produce undefined.
|
||||
const json = JSON.stringify(value)
|
||||
return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a round-tripped `execute` return against the two shapes
|
||||
* {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or
|
||||
* `{ content: blocks, meta? }`. The registry trusts the shape blindly — it
|
||||
* spreads `result.content`, so an unvalidated `{ content: 'ok' }` would enter
|
||||
* the session log as `['o','k']` and silently corrupt the next model request —
|
||||
* so a wrong shape fails THIS call with a teaching error instead.
|
||||
* Validate and host-materialize a sandbox renderer's content blocks.
|
||||
*/
|
||||
function assertExecuteReturn(value: unknown): ToolExecuteReturn {
|
||||
function assertRenderedContent(value: JsonValue): ContentBlock[] {
|
||||
if (Array.isArray(value) && value.every(isContentBlockShape)) {
|
||||
return value as ToolExecuteReturn
|
||||
}
|
||||
if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) {
|
||||
return value as ToolExecuteReturn
|
||||
return value as unknown as ContentBlock[]
|
||||
}
|
||||
throw new Error(
|
||||
`execute returned ${describeReturn(value)} — a tool's execute must return an ARRAY of content blocks, never a bare string:\n`
|
||||
+ ' ✓ return [{ type: \'text\', text: someString }]\n'
|
||||
+ ' ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }',
|
||||
`output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n`
|
||||
+ ' ✓ return [{ type: \'text\', text: String(value) }]',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -294,23 +285,46 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn {
|
||||
* @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper.
|
||||
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
|
||||
*/
|
||||
export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
|
||||
const normalized = normalizeParameterSchemaSpec((options as { parameters?: unknown }).parameters)
|
||||
const tool = defineTool({ ...options, parameters: normalized.spec } as Parameters<typeof defineTool>[0])
|
||||
export function sandboxDefineTool(options: unknown): ToolDefinition {
|
||||
if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object')
|
||||
const normalized = normalizeParameterSchemaSpec(options.parameters)
|
||||
if (!isPlainRecord(options.output)) {
|
||||
throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }')
|
||||
}
|
||||
const output = options.output
|
||||
if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function')
|
||||
if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') {
|
||||
throw new Error('harness.defineTool output.presentationMeta must be a function when present')
|
||||
}
|
||||
if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
|
||||
const schema = normalizeValueSchema(output.schema, 'output.schema')
|
||||
const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
|
||||
const rawRender = output.render as (args: unknown, value: unknown) => unknown
|
||||
const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined
|
||||
const erasedDefineTool = defineTool as unknown as (definition: unknown) => ToolDefinition
|
||||
const tool = erasedDefineTool({
|
||||
...options,
|
||||
parameters: normalized.spec,
|
||||
output: {
|
||||
schema,
|
||||
render(args: unknown, value: unknown): ContentBlock[] {
|
||||
return assertRenderedContent(cloneJson(rawRender(args, value), 'output.render result') as JsonValue)
|
||||
},
|
||||
...rawPresentationMeta !== undefined ? {
|
||||
presentationMeta(args: unknown, value: unknown): JsonValue {
|
||||
return cloneJson(rawPresentationMeta(args, value), 'output.presentationMeta result') as JsonValue
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args: unknown, exec: unknown): Promise<JsonValue> {
|
||||
return cloneJson(await rawExecute(args, exec), 'execute result') as JsonValue
|
||||
},
|
||||
})
|
||||
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
|
||||
assertSupportedJsonSchema(parameters)
|
||||
const execute = tool.execute.bind(tool)
|
||||
return markDynamicTool({
|
||||
...tool,
|
||||
parameters,
|
||||
async execute(args, exec) {
|
||||
// JSON.stringify yields NO JSON for an undefined (or function/symbol)
|
||||
// return despite its string-typed signature — route that into
|
||||
// assertExecuteReturn's teaching error rather than letting JSON.parse
|
||||
// throw its cryptic '"undefined" is not valid JSON'.
|
||||
const json = JSON.stringify(await execute(args, exec)) as string | undefined
|
||||
return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { STATE_LABELS } from './fiber-state.ts'
|
||||
import { isPlugin, pluginName } from './guard.ts'
|
||||
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
|
||||
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts'
|
||||
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts'
|
||||
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
|
||||
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
|
||||
import { createSandbox, evaluateMountCode } from './sandbox.ts'
|
||||
@@ -76,7 +76,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".',
|
||||
},
|
||||
},
|
||||
execute(args, exec): Promise<{ type: 'text'; text: string }[]> {
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(args, exec): Promise<string> {
|
||||
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
|
||||
throw new Error('name is valid only with what:"api" or what:"events"')
|
||||
}
|
||||
@@ -94,7 +98,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const text = selected
|
||||
.map(([heading, body]) => `## ${heading}\n${body().join('\n')}`)
|
||||
.join('\n\n')
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
return Promise.resolve(text)
|
||||
},
|
||||
presentCall: presentInspectCall,
|
||||
}))
|
||||
@@ -119,13 +123,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
+ 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe '
|
||||
+ 'events (see cordis_inspect what:"events"), or call '
|
||||
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
|
||||
+ '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
|
||||
+ '{ text: { type: \'string\', required: true } }, output: { schema: { type: \'string\' }, '
|
||||
+ 'render(_args, value) { return [{ type: \'text\', text: value }] } }, async execute(args) { return args.text } }))` '
|
||||
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
|
||||
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', '
|
||||
+ 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and '
|
||||
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
|
||||
+ 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return '
|
||||
+ '[{ type: \'text\', text: someString }]` — never a bare string. '
|
||||
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
|
||||
+ '`output.render(args, value)` separately returns Native/model content blocks. '
|
||||
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
|
||||
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
|
||||
+ 'until the provider exists and returns to pending when the provider is unmounted. '
|
||||
@@ -157,6 +162,32 @@ export function apply(ctx: Context, config: Config): void {
|
||||
description: 'Body of an async JS function; must `return` the plugin to mount.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
pluginName: { type: 'string', required: true },
|
||||
state: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'],
|
||||
},
|
||||
provides: { type: 'array', required: true, items: { type: 'string' } },
|
||||
waitingFor: { type: 'array', required: true, items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const note = value.waitingFor.length > 0
|
||||
? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)`
|
||||
: ''
|
||||
return [{
|
||||
type: 'text',
|
||||
text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`,
|
||||
}]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
const id = `dyn-${nextId++}`
|
||||
const sandbox = createSandbox(id)
|
||||
@@ -180,10 +211,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// it mounted but tell the model what it is waiting for.
|
||||
const missing = missingServices(ctx, fiber)
|
||||
const state = STATE_LABELS[fiber.state]
|
||||
const note = missing.length > 0
|
||||
? ` — waiting for service(s): ${missing.join(', ')} (activates when provided)`
|
||||
: ''
|
||||
return [{ type: 'text', text: `mounted ${id} (plugin "${pluginName(evaluated)}", state: ${state}${note})` }]
|
||||
return {
|
||||
id,
|
||||
pluginName: pluginName(evaluated),
|
||||
state,
|
||||
provides: providedServices(ctx, fiber),
|
||||
waitingFor: missing,
|
||||
}
|
||||
},
|
||||
presentCall: presentMountCall,
|
||||
}))
|
||||
@@ -202,6 +236,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
pluginName: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }],
|
||||
},
|
||||
async execute(args) {
|
||||
const mount = mounts.get(args.id)
|
||||
if (!mount) {
|
||||
@@ -209,7 +254,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
await mount.fiber.dispose()
|
||||
mounts.delete(args.id)
|
||||
return [{ type: 'text', text: `unmounted ${args.id} (plugin "${mount.pluginName}")` }]
|
||||
return { id: args.id, pluginName: mount.pluginName }
|
||||
},
|
||||
presentCall: presentUnmountCall,
|
||||
}))
|
||||
|
||||
@@ -33,8 +33,13 @@ function withinFiber(fiber: Fiber, root: Fiber): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** The service names provided by a mount's fiber subtree, sorted. */
|
||||
function providedBy(ctx: Context, fiber: Fiber): string[] {
|
||||
/**
|
||||
* Return the service names provided by a mount's fiber subtree.
|
||||
* @param ctx - the runtime whose service registrations are inspected.
|
||||
* @param fiber - the root of the mounted fiber subtree.
|
||||
* @returns the provided service names in lexical order.
|
||||
*/
|
||||
export function providedServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return liveImpls(ctx)
|
||||
.filter(impl => withinFiber(impl.fiber, fiber))
|
||||
.map(impl => impl.name)
|
||||
@@ -96,7 +101,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
|
||||
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
|
||||
if (mounts.size === 0) return ['(no dynamic plugins mounted)']
|
||||
return [...mounts].map(([id, mount]) => {
|
||||
const provides = providedBy(ctx, mount.fiber)
|
||||
const provides = providedServices(ctx, mount.fiber)
|
||||
const waiting = missingServices(ctx, mount.fiber)
|
||||
const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : ''
|
||||
const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : ''
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, CONSUMER_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
|
||||
import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Cross-mount composition through ordinary cordis provide/inject semantics:
|
||||
@@ -116,6 +116,7 @@ describe('cross-mount provide/inject', () => {
|
||||
name: 'answer',
|
||||
description: 'Read the provided primitive services.',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
|
||||
},
|
||||
|
||||
@@ -45,6 +45,13 @@ export const LISTENER_CODE = `
|
||||
}
|
||||
`
|
||||
|
||||
/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */
|
||||
export const CONTENT_OUTPUT_CODE = `
|
||||
output: {
|
||||
schema: { type: 'array', items: { type: 'json' } },
|
||||
render(_args, value) { return value },
|
||||
},`
|
||||
|
||||
/** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
|
||||
export const REVERSE_TOOL_CODE = `
|
||||
return {
|
||||
@@ -55,8 +62,14 @@ export const REVERSE_TOOL_CODE = `
|
||||
name: 'reverse_text',
|
||||
description: 'Reverse a string.',
|
||||
parameters: { text: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) {
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: args.text.split('').reverse().join('') }]
|
||||
return args.text.split('').reverse().join('')
|
||||
},
|
||||
}))
|
||||
},
|
||||
@@ -83,8 +96,14 @@ export const CONSUMER_CODE = `
|
||||
name: 'greet',
|
||||
description: 'Greet someone via the greeter service.',
|
||||
parameters: { name: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) {
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: ctx.greeter.greet(args.name) }]
|
||||
return ctx.greeter.greet(args.name)
|
||||
},
|
||||
}))
|
||||
},
|
||||
@@ -97,8 +116,9 @@ export function dummyTool(name: string): ToolDefinition {
|
||||
name,
|
||||
description: 'test trigger',
|
||||
parameters: { type: 'object' as const, properties: {} },
|
||||
async execute(): Promise<[]> {
|
||||
return []
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
async execute(): Promise<null> {
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('cordis_inspect', () => {
|
||||
const result = await call(ctx, 'cordis_inspect', {})
|
||||
expect(result.isError).toBe(false)
|
||||
const report = text(result)
|
||||
if (result.isError) throw new Error('expected cordis_inspect success')
|
||||
expect(result.value).toBe(report)
|
||||
for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) {
|
||||
expect(report).toContain(`## ${heading}`)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { sandboxDefineTool } from '../src/guard.ts'
|
||||
import { syntaxErrorContext } from '../src/sandbox.ts'
|
||||
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
|
||||
import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The `cordis_mount` success/failure family: real plugins land on a genuine
|
||||
@@ -14,12 +15,48 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('cordis_mount', () => {
|
||||
it.each([
|
||||
[42, 'options must be an object'],
|
||||
[{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'],
|
||||
[{ parameters: {}, output: { schema: { type: 'json' } }, execute: async (): Promise<null> => null }, 'output.render must be a function'],
|
||||
[{ parameters: {}, output: { schema: { type: 'json' }, render: () => [] }, execute: true }, 'execute must be a function'],
|
||||
[{
|
||||
parameters: {},
|
||||
output: { schema: { type: 'json' }, render: () => [], presentationMeta: true },
|
||||
execute: async (): Promise<null> => null,
|
||||
}, 'output.presentationMeta must be a function'],
|
||||
])('rejects an invalid dynamic tool declaration before registration: %j', (definition, message) => {
|
||||
expect(() => sandboxDefineTool(definition)).toThrow(message)
|
||||
})
|
||||
|
||||
it('bounds the preview of an invalid dynamic renderer return', () => {
|
||||
const definition = sandboxDefineTool({
|
||||
name: 'invalid-renderer',
|
||||
description: 'invalid renderer',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => ['x'.repeat(500)],
|
||||
},
|
||||
execute: async () => 'ok',
|
||||
})
|
||||
expect(() => definition.output.render({}, 'ok')).toThrow(/output\.render returned \["x+…/)
|
||||
})
|
||||
|
||||
it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_mount success')
|
||||
expect(result.value).toEqual({
|
||||
id: 'dyn-1',
|
||||
pluginName: 'change-logger',
|
||||
state: 'active',
|
||||
provides: [],
|
||||
waitingFor: [],
|
||||
})
|
||||
expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)')
|
||||
|
||||
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
|
||||
@@ -44,6 +81,8 @@ describe('cordis_mount', () => {
|
||||
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
|
||||
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
|
||||
expect(reversed.isError).toBe(false)
|
||||
if (reversed.isError) throw new Error('expected dynamic tool success')
|
||||
expect(reversed.value).toBe('ssenrah')
|
||||
expect(text(reversed)).toBe('ssenrah')
|
||||
})
|
||||
|
||||
@@ -55,7 +94,7 @@ describe('cordis_mount', () => {
|
||||
expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
|
||||
})
|
||||
|
||||
it('threads the { content, meta } object return form through to the registry result', async () => {
|
||||
it('projects presentation metadata from a dynamic canonical value', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
@@ -67,8 +106,13 @@ describe('cordis_mount', () => {
|
||||
name: 'meta_tool',
|
||||
description: 'attaches a private presentation payload',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) { return [{ type: 'text', text: value }] },
|
||||
presentationMeta() { return { kind: 'demo' } },
|
||||
},
|
||||
async execute() {
|
||||
return { content: [{ type: 'text', text: 'ok' }], meta: { kind: 'demo' } }
|
||||
return 'ok'
|
||||
},
|
||||
}))
|
||||
},
|
||||
@@ -77,20 +121,20 @@ describe('cordis_mount', () => {
|
||||
})
|
||||
const result = await call(ctx, 'meta_tool', {})
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected dynamic tool success')
|
||||
expect(result.value).toBe('ok')
|
||||
expect(text(result)).toBe('ok')
|
||||
expect(result.meta).toEqual({ kind: 'demo' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a bare string', 'return \'ok\'', '"ok"'],
|
||||
['an object whose content is a string', 'return { content: \'ok\' }', '{"content":"ok"}'],
|
||||
['an array of non-objects', 'return [\'ok\']', '["ok"]'],
|
||||
['blocks missing the type tag', 'return [{ text: \'hi\' }]', '[{"text":"hi"}]'],
|
||||
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'],
|
||||
['undefined — a forgotten return', 'return undefined', 'undefined'],
|
||||
])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => {
|
||||
// The registry spreads result.content, so { content: 'ok' } would become ['o','k']; reject
|
||||
// it as this call's error before it corrupts the next request.
|
||||
['a bare string', 'return \'ok\'', 'returned invalid output: "value" must be an array'],
|
||||
['an object whose content is a string', 'return { content: \'ok\' }', 'returned invalid output: "value" must be an array'],
|
||||
['an array of non-objects', 'return [\'ok\']', 'output.render returned ["ok"]'],
|
||||
['blocks missing the type tag', 'return [{ text: \'hi\' }]', 'output.render returned [{"text":"hi"}]'],
|
||||
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', 'returned invalid output: "value" must be an array'],
|
||||
['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'],
|
||||
])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
@@ -102,6 +146,7 @@ describe('cordis_mount', () => {
|
||||
name: 'bad_return_tool',
|
||||
description: 'returns a wrong shape',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { ${returnStatement} },
|
||||
}))
|
||||
},
|
||||
@@ -112,12 +157,10 @@ describe('cordis_mount', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content[0]!.type).toBe('text')
|
||||
expect(text(result)).toContain(`execute returned ${preview}`)
|
||||
expect(text(result)).toContain('must return an ARRAY of content blocks')
|
||||
expect(text(result)).toContain('✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }')
|
||||
expect(text(result)).toContain(diagnostic)
|
||||
})
|
||||
|
||||
it('truncates a huge invalid execute return in the teaching error', async () => {
|
||||
it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
@@ -129,6 +172,7 @@ describe('cordis_mount', () => {
|
||||
name: 'huge_return_tool',
|
||||
description: 'returns a huge wrong shape',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return 'x'.repeat(500) },
|
||||
}))
|
||||
},
|
||||
@@ -137,7 +181,7 @@ describe('cordis_mount', () => {
|
||||
})
|
||||
const result = await call(ctx, 'huge_return_tool', {})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('…')
|
||||
expect(text(result)).toContain('returned invalid output')
|
||||
expect(text(result)).not.toContain('x'.repeat(200))
|
||||
})
|
||||
|
||||
@@ -167,6 +211,7 @@ describe('cordis_mount', () => {
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] },
|
||||
}))
|
||||
},
|
||||
@@ -215,6 +260,7 @@ describe('cordis_mount', () => {
|
||||
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
|
||||
},
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
|
||||
}))
|
||||
},
|
||||
@@ -254,6 +300,7 @@ describe('cordis_mount', () => {
|
||||
closed: { type: 'object', additionalProperties: false },
|
||||
count: { type: 'number', enum: [1, 2], const: 1 },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: String(args.choice) }] },
|
||||
}))
|
||||
},
|
||||
@@ -299,6 +346,7 @@ describe('cordis_mount', () => {
|
||||
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
@@ -356,6 +404,7 @@ describe('cordis_mount', () => {
|
||||
name: 'bad_schema_tool',
|
||||
description: 'bad',
|
||||
${parameters},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
@@ -381,6 +430,7 @@ describe('cordis_mount', () => {
|
||||
item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: args.item.label }] },
|
||||
}))
|
||||
},
|
||||
@@ -404,6 +454,7 @@ describe('cordis_mount', () => {
|
||||
name: 'raw_dynamic_tool',
|
||||
description: 'raw',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
@@ -457,6 +508,14 @@ describe('cordis_mount', () => {
|
||||
code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected pending cordis_mount success')
|
||||
expect(result.value).toEqual({
|
||||
id: 'dyn-1',
|
||||
pluginName: 'waiter',
|
||||
state: 'pending',
|
||||
provides: [],
|
||||
waitingFor: ['no-such-service'],
|
||||
})
|
||||
expect(text(result)).toContain('state: pending')
|
||||
expect(text(result)).toContain('waiting for service(s): no-such-service')
|
||||
// Unmounting a pending mount works like any other.
|
||||
@@ -517,6 +576,7 @@ describe('cordis_mount', () => {
|
||||
name: 'cordis_mount',
|
||||
description: 'dup',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
}))
|
||||
},
|
||||
@@ -663,6 +723,7 @@ describe('cordis_mount', () => {
|
||||
name: 'probe_instanceof',
|
||||
description: 'report instanceof checks across realms',
|
||||
parameters: { items: { type: 'array', required: true, items: { type: 'string' } } },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) {
|
||||
const checks = {
|
||||
hostArray: args.items instanceof Array,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, setup, text } from './helpers.ts'
|
||||
import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only
|
||||
@@ -51,6 +51,7 @@ describe('sandbox context façade — escape surface is closed', () => {
|
||||
name: 'smuggled',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
@@ -87,6 +88,7 @@ describe('sandbox context façade — escape surface is closed', () => {
|
||||
name: 'smuggled_via_service',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
@@ -116,6 +118,7 @@ describe('sandbox context façade — escape surface is closed', () => {
|
||||
name: 'do_fetch',
|
||||
description: 'awaits the host async service',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
const value = await ctx.hostAsync.grab()
|
||||
return [{ type: 'text', text: value }]
|
||||
@@ -203,6 +206,7 @@ describe('sandbox context façade — inject gate on services', () => {
|
||||
name: 'greet_undeclared',
|
||||
description: 'uses greeter without declaring it',
|
||||
parameters: { n: { type: 'string', required: true } },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] },
|
||||
}))
|
||||
},
|
||||
@@ -235,6 +239,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
|
||||
name: 'report_view',
|
||||
description: 'reports the shape of a tool view',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
const view = ctx.tools.get('cordis_mount')
|
||||
return [{ type: 'text', text: JSON.stringify({
|
||||
@@ -270,6 +275,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
|
||||
name: 'probe_unknown',
|
||||
description: 'reports whether an unknown tool resolves',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }]
|
||||
},
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('cordis_unmount', () => {
|
||||
|
||||
const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected cordis_unmount success')
|
||||
expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' })
|
||||
expect(text(result)).toContain('unmounted dyn-1')
|
||||
|
||||
// Immediately after the awaited unmount, the listener must be gone — no
|
||||
|
||||
Reference in New Issue
Block a user