• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
1571
packages/extensions/tool-cordis/src/client-catalog.ts
Normal file
1571
packages/extensions/tool-cordis/src/client-catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,780 +0,0 @@
|
||||
/**
|
||||
* The registration boundary between sandboxed mount code and the real runtime: ParameterSchemaSpec
|
||||
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
|
||||
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives
|
||||
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
|
||||
* 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 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
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Plugin } from '@deepseek-ai/cordis'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { assertSupportedJsonSchema, defineTool } 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'])
|
||||
const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\''
|
||||
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
|
||||
|
||||
type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
|
||||
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
return prototype === null
|
||||
|| typeof prototype === 'object'
|
||||
&& Object.getPrototypeOf(prototype) === null
|
||||
&& hasIntrinsicConstructor(prototype, 'Object')
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */
|
||||
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
|
||||
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const constructor: unknown = descriptor?.value
|
||||
if (typeof constructor !== 'function') return false
|
||||
try {
|
||||
return constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
|
||||
function hasPlainArrayPrototype(value: unknown[]): boolean {
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
|
||||
const objectPrototype: unknown = Object.getPrototypeOf(prototype)
|
||||
return typeof objectPrototype === 'object'
|
||||
&& objectPrototype !== null
|
||||
&& Object.getPrototypeOf(objectPrototype) === null
|
||||
&& hasIntrinsicConstructor(objectPrototype, 'Object')
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Whether a schema list is a dense intrinsic array with no JSON-invisible decorations. */
|
||||
function isDensePlainArray(value: unknown): value is unknown[] {
|
||||
if (!Array.isArray(value) || !hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Reject schema records whose declarations would disappear from object enumeration. */
|
||||
function assertSchemaContainerKeys(value: Record<string, unknown>, path: string): void {
|
||||
if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) {
|
||||
throw new Error(`harness.defineTool ${path} must contain only own enumerable string keys`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where one cloned JSON value is installed. */
|
||||
type CloneDestination =
|
||||
| { kind: 'root' }
|
||||
| { kind: 'array'; target: unknown[]; index: number }
|
||||
| { kind: 'object'; target: Record<string, unknown>; key: string }
|
||||
|
||||
/** Deferred work for stack-safe cross-realm JSON cloning. */
|
||||
type CloneTask =
|
||||
| { kind: 'visit'; value: unknown; path: string; destination: CloneDestination }
|
||||
| { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
|
||||
| { kind: 'leave'; source: object }
|
||||
|
||||
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
|
||||
function cloneJson(value: unknown, path: string): unknown {
|
||||
const ancestors = new Set<object>()
|
||||
let root: unknown
|
||||
const assign = (destination: CloneDestination, item: unknown): void => {
|
||||
if (destination.kind === 'root') {
|
||||
root = item
|
||||
return
|
||||
}
|
||||
if (destination.kind === 'array') {
|
||||
destination.target[destination.index] = item
|
||||
return
|
||||
}
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value: item,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
const reject = (at: string): never => {
|
||||
throw new Error(`harness.defineTool ${at} must be lossless JSON data`)
|
||||
}
|
||||
|
||||
const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
ancestors.delete(task.source)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'array-item') {
|
||||
if (!Object.hasOwn(task.source, task.index)) reject(task.path)
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: task.source[task.index],
|
||||
path: `${task.path}[${task.index}]`,
|
||||
destination: { kind: 'array', target: task.target, index: task.index },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const current = task.value
|
||||
if (current === null || typeof current === 'string' || typeof current === 'boolean') {
|
||||
assign(task.destination, current)
|
||||
continue
|
||||
}
|
||||
if (typeof current === 'number') {
|
||||
if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path)
|
||||
assign(task.destination, current)
|
||||
continue
|
||||
}
|
||||
if (typeof current !== 'object' || ancestors.has(current)) reject(task.path)
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path)
|
||||
const output: unknown[] = []
|
||||
assign(task.destination, output)
|
||||
ancestors.add(current)
|
||||
tasks.push({ kind: 'leave', source: current })
|
||||
for (let index = current.length - 1; index >= 0; index--) {
|
||||
tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!isPlainRecord(current)) reject(task.path)
|
||||
const record = current as Record<string, unknown>
|
||||
if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) {
|
||||
reject(task.path)
|
||||
}
|
||||
const output: Record<string, unknown> = {}
|
||||
assign(task.destination, output)
|
||||
ancestors.add(record)
|
||||
tasks.push({ kind: 'leave', source: record })
|
||||
const entries = Object.entries(record)
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: entry[1],
|
||||
path: `${task.path}.${entry[0]}`,
|
||||
destination: { kind: 'object', target: output, key: entry[0] },
|
||||
})
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Copy and realm-materialize the shared annotation vocabulary. */
|
||||
function copyAnnotations(value: Record<string, unknown>, output: Record<string, unknown>, path: string): void {
|
||||
if (Object.hasOwn(value, 'description')) output.description = value.description
|
||||
if (Object.hasOwn(value, 'title')) output.title = value.title
|
||||
if (Object.hasOwn(value, 'default')) output.default = cloneJson(value.default, `${path}.default`)
|
||||
if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `${path}.examples`)
|
||||
}
|
||||
|
||||
/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
|
||||
function assertSchemaKeys(value: Record<string, unknown>, path: string, allowed: readonly string[]): void {
|
||||
assertSchemaContainerKeys(value, path)
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a sandbox-provided `parameters` value into a fresh host-realm
|
||||
* ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root
|
||||
* default, while the direct DSL is already an implicit open property map.
|
||||
*/
|
||||
function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
|
||||
spec: Record<string, unknown>
|
||||
rootAnnotations?: Record<string, unknown>
|
||||
} {
|
||||
if (!isPlainRecord(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`)
|
||||
}
|
||||
if (value.type === 'object') {
|
||||
assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS])
|
||||
if (!isPlainRecord(value.properties)) {
|
||||
throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'required') && value.required === undefined) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
|
||||
}
|
||||
const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`)
|
||||
const rootAnnotations: Record<string, unknown> = {}
|
||||
copyAnnotations(value, rootAnnotations, path)
|
||||
return {
|
||||
spec: normalizePropertyMap(value.properties, path, required, true),
|
||||
...(Object.keys(rootAnnotations).length === 0 ? {} : { rootAnnotations }),
|
||||
}
|
||||
}
|
||||
return { spec: normalizePropertyMap(value, path, new Set(), false) }
|
||||
}
|
||||
|
||||
/** Validate raw required names and return their lookup set. */
|
||||
function normalizeRequiredNames(value: unknown, properties: Record<string, unknown>, path: string): Set<string> {
|
||||
if (value === undefined) return new Set()
|
||||
if (!isDensePlainArray(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
|
||||
}
|
||||
const names = new Set<string>()
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const name = value[index]
|
||||
if (typeof name !== 'string') {
|
||||
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
|
||||
}
|
||||
names.add(name)
|
||||
if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** Mutable holder used only while one normalized property-map root is unresolved. */
|
||||
interface NormalizeRoot {
|
||||
value?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Where a normalized value node is installed. */
|
||||
type NormalizeValueDestination =
|
||||
| { kind: 'property'; target: Record<string, unknown>; key: string }
|
||||
| { kind: 'item'; target: Record<string, unknown> }
|
||||
| { kind: 'one-of'; target: Record<string, unknown>[]; index: number }
|
||||
|
||||
/** Where a normalized property map is installed. */
|
||||
type NormalizeMapDestination =
|
||||
| { kind: 'root'; holder: NormalizeRoot }
|
||||
| { kind: 'properties'; target: Record<string, unknown> }
|
||||
|
||||
/** Deferred work for stack-safe sandbox schema normalization. */
|
||||
type NormalizeTask =
|
||||
| {
|
||||
kind: 'map'
|
||||
entries: Record<string, unknown>
|
||||
path: string
|
||||
requiredNames: ReadonlySet<string>
|
||||
raw: boolean
|
||||
destination: NormalizeMapDestination
|
||||
}
|
||||
| {
|
||||
kind: 'value'
|
||||
value: unknown
|
||||
path: string
|
||||
forceRequired: boolean
|
||||
raw: boolean
|
||||
parameterProperty: boolean
|
||||
destination: NormalizeValueDestination
|
||||
}
|
||||
| { kind: 'leave'; value: object }
|
||||
|
||||
/** Install one normalized node without `__proto__` assignment semantics. */
|
||||
function assignNormalizedValue(destination: NormalizeValueDestination, value: Record<string, unknown>): void {
|
||||
if (destination.kind === 'property') {
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
} else if (destination.kind === 'item') {
|
||||
destination.target.items = value
|
||||
} else {
|
||||
destination.target[destination.index] = value
|
||||
}
|
||||
}
|
||||
|
||||
/** Install one normalized property map at its root or containing object. */
|
||||
function assignNormalizedMap(destination: NormalizeMapDestination, value: Record<string, unknown>): void {
|
||||
if (destination.kind === 'root') destination.holder.value = value
|
||||
else destination.target.properties = value
|
||||
}
|
||||
|
||||
/** Normalize one implicit property map and all descendants with explicit work frames. */
|
||||
function normalizePropertyMap(
|
||||
entries: Record<string, unknown>,
|
||||
path: string,
|
||||
requiredNames: ReadonlySet<string>,
|
||||
raw: boolean,
|
||||
): Record<string, unknown> {
|
||||
const holder: NormalizeRoot = {}
|
||||
const ancestors = new Set<object>()
|
||||
const tasks: NormalizeTask[] = [{
|
||||
kind: 'map',
|
||||
entries,
|
||||
path,
|
||||
requiredNames,
|
||||
raw,
|
||||
destination: { kind: 'root', holder },
|
||||
}]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
ancestors.delete(task.value)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'map') {
|
||||
if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`)
|
||||
assertSchemaContainerKeys(task.entries, task.path)
|
||||
ancestors.add(task.entries)
|
||||
const spec: Record<string, unknown> = {}
|
||||
assignNormalizedMap(task.destination, spec)
|
||||
tasks.push({ kind: 'leave', value: task.entries })
|
||||
const mapEntries = Object.entries(task.entries)
|
||||
for (let index = mapEntries.length - 1; index >= 0; index--) {
|
||||
const entry = mapEntries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: entry[1],
|
||||
path: `${task.path}.${entry[0]}`,
|
||||
forceRequired: task.requiredNames.has(entry[0]),
|
||||
raw: task.raw,
|
||||
parameterProperty: true,
|
||||
destination: { kind: 'property', target: spec, key: entry[0] },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const { value, path } = task
|
||||
if (!isPlainRecord(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
|
||||
}
|
||||
assertSchemaContainerKeys(value, path)
|
||||
if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`)
|
||||
ancestors.add(value)
|
||||
const requiredKey = task.parameterProperty && !task.raw ? ['required'] : []
|
||||
if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
|
||||
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
|
||||
}
|
||||
if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be true when present`)
|
||||
}
|
||||
const prop: Record<string, unknown> = {}
|
||||
assignNormalizedValue(task.destination, prop)
|
||||
tasks.push({ kind: 'leave', value })
|
||||
if (task.forceRequired || value.required === true) prop.required = true
|
||||
copyAnnotations(value, prop, path)
|
||||
|
||||
if (Object.hasOwn(value, 'oneOf')) {
|
||||
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (!isDensePlainArray(value.oneOf) || value.oneOf.length < 2) {
|
||||
throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
|
||||
}
|
||||
const oneOf: Record<string, unknown>[] = []
|
||||
prop.oneOf = oneOf
|
||||
for (let index = value.oneOf.length - 1; index >= 0; index--) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: value.oneOf[index],
|
||||
path: `${path}.oneOf[${index}]`,
|
||||
forceRequired: false,
|
||||
raw: task.raw,
|
||||
parameterProperty: false,
|
||||
destination: { kind: 'one-of', target: oneOf, index },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.raw && !Object.hasOwn(value, 'type')) {
|
||||
assertSchemaKeys(value, path, ANNOTATION_KEYS)
|
||||
prop.type = 'json'
|
||||
continue
|
||||
}
|
||||
if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') {
|
||||
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
|
||||
}
|
||||
const type = value.type
|
||||
prop.type = type
|
||||
|
||||
switch (type) {
|
||||
case 'object': {
|
||||
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS])
|
||||
if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
|
||||
}
|
||||
if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
|
||||
}
|
||||
if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
|
||||
}
|
||||
prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties
|
||||
if (Object.hasOwn(value, 'properties')) {
|
||||
const properties = value.properties
|
||||
if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
|
||||
const nestedRequired = task.raw
|
||||
? normalizeRequiredNames(value.required, properties, `${path}.required`)
|
||||
: new Set<string>()
|
||||
tasks.push({
|
||||
kind: 'map',
|
||||
entries: properties,
|
||||
path: `${path}.properties`,
|
||||
requiredNames: nestedRequired,
|
||||
raw: task.raw,
|
||||
destination: { kind: 'properties', target: prop },
|
||||
})
|
||||
} else if (task.raw && value.required !== undefined) {
|
||||
normalizeRequiredNames(value.required, {}, `${path}.required`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'array':
|
||||
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (Object.hasOwn(value, 'items')) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: value.items,
|
||||
path: `${path}.items`,
|
||||
forceRequired: false,
|
||||
raw: task.raw,
|
||||
parameterProperty: false,
|
||||
destination: { kind: 'item', target: prop },
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'null':
|
||||
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (Object.hasOwn(value, 'enum')) {
|
||||
if (!isDensePlainArray(value.enum) || value.enum.length === 0) {
|
||||
throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`)
|
||||
}
|
||||
prop.enum = cloneJson(value.enum, `${path}.enum`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
|
||||
break
|
||||
case 'json':
|
||||
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
break
|
||||
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
|
||||
default:
|
||||
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- the root map task assigns before scheduling descendants. */
|
||||
return holder.value ?? {}
|
||||
}
|
||||
|
||||
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
|
||||
Object.defineProperty(tool, DYNAMIC_TOOL, { value: true })
|
||||
return tool as DynamicToolDefinition
|
||||
}
|
||||
|
||||
function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition {
|
||||
if (!isPlainRecord(tool) || (tool as DynamicToolMarker)[DYNAMIC_TOOL] !== true) {
|
||||
throw new Error('dynamic tool registration must use a tool returned by harness.defineTool(...)')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally a content block, checked AFTER the JSON round-trip: a plain
|
||||
* object carrying a string `type` tag. Deliberately nothing deeper — the
|
||||
* ContentBlock union is merge-extensible (an unknown tag must pass), and every
|
||||
* downstream consumer dispatches on `type` and falls through unknowns.
|
||||
*/
|
||||
function isContentBlockShape(value: unknown): boolean {
|
||||
return isPlainRecord(value) && typeof value.type === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of an invalid execute return the teaching error echoes back — a
|
||||
* huge blob would burn the model turn the error is trying to save.
|
||||
*/
|
||||
const RETURN_PREVIEW_LIMIT = 120
|
||||
|
||||
/**
|
||||
* Compact JSON preview of an invalid execute return for the teaching error
|
||||
* (`String(…)` for the un-stringifiable undefined case), truncated to
|
||||
* {@link RETURN_PREVIEW_LIMIT}.
|
||||
*/
|
||||
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 and host-materialize a sandbox renderer's content blocks.
|
||||
*/
|
||||
function assertRenderedContent(value: JsonValue): ContentBlock[] {
|
||||
if (Array.isArray(value) && value.every(isContentBlockShape)) {
|
||||
return value as unknown as ContentBlock[]
|
||||
}
|
||||
throw new Error(
|
||||
`output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n`
|
||||
+ ' ✓ return [{ type: \'text\', text: String(value) }]',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
|
||||
* into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped,
|
||||
* required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm
|
||||
* via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning
|
||||
* the session log.
|
||||
* @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: 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 = cloneJson(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)
|
||||
return markDynamicTool({
|
||||
...tool,
|
||||
parameters,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `harness.registerTool` handed into the sandbox: registers a
|
||||
* marker-verified dynamic tool on the given context's registry.
|
||||
* @param ctx - the (guarded) context whose `tools` service receives the tool.
|
||||
* @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
|
||||
* @returns the registry disposer for the registration.
|
||||
*/
|
||||
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
|
||||
assertDynamicTool(tool)
|
||||
return ctx.tools.register(tool)
|
||||
}
|
||||
|
||||
/**
|
||||
* The verbs a mounted plugin may reach through the sandbox `ctx` façade, beyond its injected
|
||||
* services. `on`/`once` observe events, `provide` exposes a service to other mounts, and the
|
||||
* timer helpers schedule work — each a fiber effect that unwinds on unmount.
|
||||
*/
|
||||
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
|
||||
|
||||
/**
|
||||
* The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
|
||||
* metadata (`schemas`, and `get` returning a schema view, never the live
|
||||
* `ToolDefinition`). Exposing the raw definition would hand mount code the
|
||||
* tool's `execute` function, letting it call another tool directly and bypass
|
||||
* `ToolRuntime.execute` — identity protection, pre-policy, monotonic guards,
|
||||
* around dispatch, post-policy, final observation, and result normalization. So `get` returns the same
|
||||
* name/description/parameters view as `schemas()`, and nothing invocable.
|
||||
*/
|
||||
function sandboxTools(ctx: Context): Record<string, unknown> {
|
||||
// Resolve reads and writes through the mount's own scope.
|
||||
return {
|
||||
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
|
||||
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
|
||||
get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any injected-service return that is a cordis `Context`. Harness
|
||||
* services return data, never a context; a value that is one would be a
|
||||
* fresh, unguarded handle back into the runtime — the exact escape the façade
|
||||
* exists to close — so it fails loud instead of reaching sandbox code.
|
||||
*/
|
||||
function denyContext(value: unknown, service: string): unknown {
|
||||
if (value instanceof Context) {
|
||||
throw new Error(
|
||||
`service "${service}" returned a cordis Context, which the sandbox does not expose. `
|
||||
+ 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
|
||||
+ 'and the services you inject — never another context.',
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an injected service so its methods forward to the real instance but
|
||||
* their return values pass through {@link denyContext}. Non-function members
|
||||
* (plain data) pass through as-is; a returned Promise is guarded on resolve.
|
||||
*/
|
||||
function guardedService(service: object, name: string): unknown {
|
||||
return new Proxy(service, {
|
||||
get(target, prop) {
|
||||
const value = Reflect.get(target, prop, target) as unknown
|
||||
if (typeof value !== 'function') return denyContext(value, name)
|
||||
return (...args: unknown[]): unknown => {
|
||||
const result = Reflect.apply(value, target, args) as unknown
|
||||
if (result instanceof Promise) return result.then(v => denyContext(v, name))
|
||||
return denyContext(result, name)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The service names a plugin declared in `inject`, as a lookup set. Whatever
|
||||
* declaration style the plugin used — an `inject: ['bash', 'tools']` array or
|
||||
* the `{ required, optional }` object form — cordis resolves it into a single
|
||||
* name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`),
|
||||
* so the gate just reads that map's keys. A mount may reach only the services
|
||||
* it declared — that is what lets cordis park the mount when a declared
|
||||
* provider unmounts.
|
||||
*/
|
||||
function declaredInjects(ctx: Context): Set<string> {
|
||||
return new Set(Object.keys(ctx.fiber.inject))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist context for mounted plugins: lifecycle-safe verbs, guarded tools, and only declared
|
||||
* injected services. Framework plumbing is denied, and service methods cannot return a Context.
|
||||
*/
|
||||
function sandboxContext(ctx: Context): Context {
|
||||
const tools = sandboxTools(ctx)
|
||||
const declared = declaredInjects(ctx)
|
||||
// A framework member or an undeclared service — distinguish the two so the
|
||||
// error teaches the right fix (declare it in inject vs it is withheld).
|
||||
const denyRead = (prop: string): never => {
|
||||
if (ctx.get(prop) !== undefined) {
|
||||
throw new Error(
|
||||
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
|
||||
+ 'so cordis parks this temporary Plugin if the provider is later unmounted.',
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
|
||||
+ 'the timer helpers (ctx.setTimeout, ctx.interval, …) and any service you declared in inject. '
|
||||
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
|
||||
)
|
||||
}
|
||||
// Read a service for either access path (property or `get`). `tools` is the façade's own
|
||||
// API.
|
||||
const readService = (name: string): unknown => {
|
||||
if (name === 'tools') return tools
|
||||
if (!declared.has(name)) return denyRead(name)
|
||||
const service = denyContext(ctx.get(name), name)
|
||||
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
|
||||
return guardedService(service, name)
|
||||
}
|
||||
const get = (name: string): unknown => readService(name)
|
||||
return new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'tools') return tools
|
||||
if (prop === 'get') return get
|
||||
if (typeof prop !== 'string') return undefined
|
||||
// Lazy verb forwarder — reads `ctx[verb]` only when called, so a plugin
|
||||
// that never uses a timer never triggers the timer mixin's inject check
|
||||
// (cordis raises its own "without inject" error there for undeclared timer use).
|
||||
if (CTX_VERBS.has(prop)) {
|
||||
return (...args: unknown[]): unknown => {
|
||||
const method = ctx[prop as keyof Context]
|
||||
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
|
||||
}
|
||||
}
|
||||
return readService(prop)
|
||||
},
|
||||
// A façade is not the real ctx; block writes rather than let mount code
|
||||
// stash state on a throwaway object and think it persisted.
|
||||
set(_target, prop) {
|
||||
throw new Error(`sandbox ctx is read-only; cannot assign "${String(prop)}"`)
|
||||
},
|
||||
// `in` reflects reachability: the façade API plus DECLARED services
|
||||
// (whether or not currently live). Does not resolve/wrap — no throw.
|
||||
has: (_target, prop) => prop === 'tools' || prop === 'get'
|
||||
|| (typeof prop === 'string' && (CTX_VERBS.has(prop) || declared.has(prop))),
|
||||
}) as unknown as Context
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an arbitrary sandbox return value to a mountable cordis plugin: a
|
||||
* function, or an object with an `apply` function. (A bare function passes the
|
||||
* first arm, so the object arm never sees `Function.prototype.apply`.)
|
||||
* @param value - whatever the mount code returned.
|
||||
* @returns whether the value is mountable via `ctx.plugin`.
|
||||
*/
|
||||
export function isPlugin(value: unknown): value is Plugin {
|
||||
if (typeof value === 'function') return true
|
||||
return typeof value === 'object' && value !== null
|
||||
&& typeof (value as { apply?: unknown }).apply === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata.
|
||||
* @param plugin - the plugin the mount code returned.
|
||||
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
|
||||
*/
|
||||
export function guardedPlugin(plugin: Plugin): Plugin {
|
||||
if (typeof plugin === 'function') {
|
||||
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
|
||||
return {
|
||||
name: pluginName(plugin),
|
||||
apply(ctx: Context, config?: unknown) {
|
||||
return functionPlugin(sandboxContext(ctx), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
|
||||
return {
|
||||
...plugin,
|
||||
apply(ctx: Context, config?: unknown) {
|
||||
return objectPlugin.apply(sandboxContext(ctx), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for a mounted plugin: its `name` property, else anonymous.
|
||||
* @param plugin - the plugin the mount code returned.
|
||||
* @returns the human-readable name used in mount results and inspect output.
|
||||
*/
|
||||
export function pluginName(plugin: Plugin): string {
|
||||
const named = (plugin as { name?: unknown }).name
|
||||
if (typeof named === 'string' && named.length > 0) return named
|
||||
return '<anonymous>'
|
||||
}
|
||||
@@ -1,172 +1,197 @@
|
||||
/**
|
||||
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned temporary
|
||||
* plugin under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects,
|
||||
* so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent
|
||||
* accidental misuse, not hostile code: an allowed service such as `ctx.shell` reaches the real
|
||||
* runtime. Named exports preserve loader injection metadata.
|
||||
* Model-facing Cordis runtime/package inspection, define, run, stop, and remove tools.
|
||||
* @module @deepseek-ai/dsh-tool-cordis
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId,
|
||||
} from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { DynamicCordisReference } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
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, 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'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { missingServices, providedServices } from './inspect.ts'
|
||||
import {
|
||||
presentDefineCall, presentInspectListCall, presentInspectQueryCall, presentInspectSelfCall, presentRunCall,
|
||||
presentStopCall, presentUndefineCall,
|
||||
} from './present.ts'
|
||||
import { CORDIS_SYSTEM_PROMPT } from './prompt.ts'
|
||||
import { hostInspectProviders } from './providers.ts'
|
||||
|
||||
export const name = 'tool-cordis'
|
||||
export const inject = ['tools']
|
||||
export const inject = ['tools', 'systemPrompt', 'dynamicCordisRunner', 'cordisInspect']
|
||||
|
||||
/** Config for the tool-cordis plugin: the sandbox evaluation bound. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
|
||||
* before evaluation is aborted (default 5000). An async body escapes this
|
||||
* bound — see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
|
||||
*/
|
||||
vmTimeoutMs?: number
|
||||
function requireAgent(exec: ToolExecution): Agent {
|
||||
if (exec.agent === undefined) throw new Error('Cordis dynamic tools require an Agent-backed session')
|
||||
return exec.agent
|
||||
}
|
||||
|
||||
/** Schemastery validator for {@link Config}: `vmTimeoutMs` must be at least 1 (defaults to 5000). */
|
||||
export const Config: z<Config> = z.object({
|
||||
vmTimeoutMs: z.number().min(1).default(5000),
|
||||
})
|
||||
|
||||
/** {@link Config} with every defaulted field present, as schemastery resolves it at load. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* Register the three cordis tools and own every temporary plugin under one
|
||||
* `cordis-dynamic` group fiber.
|
||||
* @param ctx - the plugin context (`tools` injected).
|
||||
* @param config - the schemastery-resolved {@link Config}.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const { vmTimeoutMs } = config as ResolvedConfig
|
||||
// The one group fiber every dynamic mount hangs under.
|
||||
const group = ctx.plugin({ name: 'cordis-dynamic', apply: () => {} })
|
||||
|
||||
const mounts = new Map<string, DynamicMount>()
|
||||
let nextId = 1
|
||||
/** Register the Cordis tools and explicit `@pluginId` context injection. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.systemPrompt.section({ name: 'tool:cordis', order: 115, text: CORDIS_SYSTEM_PROMPT })
|
||||
for (const provider of hostInspectProviders(ctx)) {
|
||||
ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`)
|
||||
}
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect',
|
||||
name: 'cordis_inspect_list',
|
||||
description:
|
||||
'Inspect the live Cordis runtime in the current DSH process. Read-only. '
|
||||
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
|
||||
+ '`plugins` (all live plugin fibers with their lifecycle states), '
|
||||
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
|
||||
+ '`temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), '
|
||||
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
|
||||
+ '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
|
||||
+ 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. '
|
||||
+ 'The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. '
|
||||
+ 'With `what:"api"` or `what:"events"`, pass an exact `name` '
|
||||
+ 'to narrow to one service/event and include its original source JSDoc.',
|
||||
parameters: {
|
||||
what: {
|
||||
type: 'string',
|
||||
enum: ['services', 'plugins', 'tools', 'temporary', 'api', 'events'],
|
||||
description: 'Limit the report to one section. Omit for all sections.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".',
|
||||
},
|
||||
},
|
||||
'List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest '
|
||||
+ 'manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and '
|
||||
+ 'input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and '
|
||||
+ 'method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business '
|
||||
+ 'Service that Plugin code can call.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
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"')
|
||||
}
|
||||
const sections: [key: string, heading: string, body: () => string[]][] = [
|
||||
['services', 'services', () => describeServices(ctx)],
|
||||
['plugins', 'plugins', () => describePlugins(ctx)],
|
||||
// The calling agent's view: scoped/shadowed tools included, restricted
|
||||
// globals absent — "what you can call", not the global registry.
|
||||
['tools', 'tools', () => describeTools(ctx, exec.agent)],
|
||||
['temporary', 'Temporary Plugins', () => describeDynamic(ctx, mounts)],
|
||||
['api', 'api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
|
||||
['events', 'events', () => describeEvents(EVENT_API, args.name)],
|
||||
]
|
||||
const selected = sections.filter(([key]) => args.what === undefined || args.what === key)
|
||||
const text = selected
|
||||
.map(([, heading, body]) => `## ${heading}\n${body().join('\n')}`)
|
||||
.join('\n\n')
|
||||
return Promise.resolve(text)
|
||||
execute(_args, _exec): Promise<JsonValue> {
|
||||
return Promise.resolve({ providers: ctx.cordisInspect.list() } as unknown as JsonValue)
|
||||
},
|
||||
presentCall: presentInspectCall,
|
||||
presentCall: presentInspectListCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_mount',
|
||||
name: 'cordis_inspect_query',
|
||||
description:
|
||||
'Mount a temporary Cordis Plugin in the current DSH process. '
|
||||
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. '
|
||||
+ 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. '
|
||||
+ 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. '
|
||||
+ 'To keep it, ask the Agent to implement an Harness Plugin or installable profile bundle through the regular development workflow. '
|
||||
+ 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. '
|
||||
+ '`code` runs now as the body of an async JavaScript function '
|
||||
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
|
||||
+ 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register '
|
||||
+ 'tools, listen to events, and provide services, but reaching ANY service (e.g. '
|
||||
+ 'ctx.shell) throws; use it only when you need no services. '
|
||||
+ 'OBJECT form `return { name?, inject: [\'bash\', \'llm\', …], apply(ctx) { … } }` '
|
||||
+ '— declares dependencies, and cordis activates the plugin only after the '
|
||||
+ 'services exist; PREFER this form. You may reach ONLY the services you list in '
|
||||
+ 'inject: an undeclared service throws even if it exists, because an undeclared '
|
||||
+ 'dependency would not be cleaned up if its provider is unmounted. '
|
||||
+ 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists '
|
||||
+ 'method signatures AND the type shapes of their arguments/returns (do not guess a '
|
||||
+ 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). '
|
||||
+ '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 } }, 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 the lossless JSON value declared by `output.schema`; '
|
||||
+ '`output.render(args, value)` separately returns Native/model content blocks. '
|
||||
+ 'Temporary Plugins 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. '
|
||||
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_unmount. '
|
||||
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
|
||||
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
|
||||
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
|
||||
+ 'Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, '
|
||||
+ 'never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect '
|
||||
+ 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
|
||||
+ 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.shell for processes, '
|
||||
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
|
||||
+ 'auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. '
|
||||
+ 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
|
||||
+ 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
|
||||
+ 'trailing `next` callback which MUST be called — returning without `next()` '
|
||||
+ 'SHORT-CIRCUITS the call; prefer plain notification events unless you intend to '
|
||||
+ 'intercept. (2) Never await something that only resolves after the current '
|
||||
+ 'turn (your code runs INSIDE a tool call of that turn — it would deadlock). '
|
||||
+ '(3) Your `ctx` is a restricted façade: you can register tools, observe '
|
||||
+ 'events, provide/consume services, and use timers, but framework internals '
|
||||
+ '(ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a '
|
||||
+ 'security boundary though — the services you inject (e.g. ctx.shell) reach the '
|
||||
+ 'real runtime.',
|
||||
'Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come '
|
||||
+ 'from cordis_inspect_list, and input must satisfy that method\'s schema. Use this Tool before cordis_define '
|
||||
+ 'to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot '
|
||||
+ 'trees and props. Host queries run locally. A Client query waits for the first valid page response and '
|
||||
+ 'remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service '
|
||||
+ 'methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate '
|
||||
+ 'the compact signature directory, then query the exact service or event for its structured contract and '
|
||||
+ 'referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the '
|
||||
+ 'exact root for its complete registration contract and props.',
|
||||
parameters: {
|
||||
code: {
|
||||
type: 'string',
|
||||
platform: { type: 'string', required: true, enum: ['host', 'client'], description: 'Runtime platform that owns the Provider.' },
|
||||
provider: { type: 'string', required: true, description: 'Exact Provider ID returned by cordis_inspect_list.' },
|
||||
method: { type: 'string', required: true, description: 'Exact method name declared by the Provider manifest.' },
|
||||
input: { type: 'json', description: 'Optional query input; it must satisfy the method input schema.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const data = await ctx.cordisInspect.query(
|
||||
args.platform,
|
||||
args.provider,
|
||||
args.method,
|
||||
args.input,
|
||||
requireAgent(exec),
|
||||
exec.signal,
|
||||
)
|
||||
return { platform: args.platform, provider: args.provider, method: args.method, data }
|
||||
},
|
||||
presentCall: presentInspectQueryCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect_self',
|
||||
description:
|
||||
'Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, '
|
||||
+ 'list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package '
|
||||
+ 'summary. Only pluginId plus packageId returns that immutable Package\'s Host/Client source and runtime '
|
||||
+ 'diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing '
|
||||
+ 'an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code '
|
||||
+ 'nor changes version pointers.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', description: 'Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin.' },
|
||||
packageId: { type: 'string', description: 'Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
execute(args, exec): Promise<JsonValue> {
|
||||
const agent = requireAgent(exec)
|
||||
if (args.packageId !== undefined && args.pluginId === undefined) {
|
||||
throw new Error('cordis_inspect_self packageId requires pluginId')
|
||||
}
|
||||
if (args.pluginId === undefined) {
|
||||
return Promise.resolve({
|
||||
mode: 'plugins',
|
||||
plugins: ctx.dynamicCordisRunner.listPlugins(agent).map(reference => selfSummary(reference)),
|
||||
} as unknown as JsonValue)
|
||||
}
|
||||
const pluginId = CordisDynamicPluginId(args.pluginId)
|
||||
if (args.packageId === undefined) {
|
||||
const plugin = ctx.dynamicCordisRunner.inspectPlugin(agent, pluginId)
|
||||
return Promise.resolve({
|
||||
mode: 'plugin',
|
||||
...selfSummary(plugin),
|
||||
packages: plugin.packages.map(pkg => ({
|
||||
...pkg,
|
||||
packageId: String(pkg.packageId),
|
||||
isCurrent: pkg.packageId === plugin.currentPackageId,
|
||||
isNext: pkg.packageId === plugin.nextPackageId,
|
||||
})),
|
||||
} as unknown as JsonValue)
|
||||
}
|
||||
return Promise.resolve(inspectSelfPackage(
|
||||
ctx,
|
||||
agent,
|
||||
pluginId,
|
||||
CordisDynamicPackageId(args.packageId),
|
||||
) as unknown as JsonValue)
|
||||
},
|
||||
presentCall: presentInspectSelfCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_define',
|
||||
description:
|
||||
'Define an immutable Cordis Package. For a new Plugin, use kind:"new" and provide only a semantic prefix of '
|
||||
+ '3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing '
|
||||
+ 'Plugin, use kind:"existing" with its exact pluginId to append a Package without overwriting older versions. '
|
||||
+ 'Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns '
|
||||
+ 'a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a '
|
||||
+ 'Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it '
|
||||
+ 'does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the '
|
||||
+ 'returned IDs.',
|
||||
parameters: {
|
||||
plugin: {
|
||||
required: true,
|
||||
description: 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.',
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', const: 'new', required: true },
|
||||
idPrefix: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', const: 'existing', required: true },
|
||||
pluginId: { type: 'string', required: true, description: 'Exact ID of an existing Plugin; the new Package is appended to that instance.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
name: { type: 'string', required: true, description: 'Short, readable Package name.' },
|
||||
purpose: { type: 'string', required: true, description: 'One-sentence, user-facing description of the Package purpose.' },
|
||||
code: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
host: { type: 'string', description: 'Plain JavaScript function body that returns the Host-half Cordis Plugin.' },
|
||||
client: { type: 'string', description: 'Plain JavaScript function body that returns the browser Client-half Cordis Plugin.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
@@ -174,93 +199,332 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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' } },
|
||||
pluginId: { type: 'string', required: true },
|
||||
packageId: { type: 'string', required: true },
|
||||
name: { type: 'string', required: true },
|
||||
purpose: { type: 'string', required: true },
|
||||
hasHostHalf: { type: 'boolean', required: true },
|
||||
hasClientHalf: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `Defined ${value.pluginId}/${value.packageId} (${value.name}); it is not running yet. `
|
||||
+ 'Use cordis_run to activate this Package.',
|
||||
}],
|
||||
presentationMeta: (_args, value) => ({ pluginId: value.pluginId, packageId: value.packageId }),
|
||||
},
|
||||
execute(args, exec) {
|
||||
const plugin = args.plugin.kind === 'new'
|
||||
? { kind: 'new' as const, idPrefix: args.plugin.idPrefix }
|
||||
: { kind: 'existing' as const, pluginId: CordisDynamicPluginId(args.plugin.pluginId) }
|
||||
const receipt = ctx.dynamicCordisRunner.define({
|
||||
sessionId: requireAgent(exec).id,
|
||||
plugin,
|
||||
name: args.name,
|
||||
purpose: args.purpose,
|
||||
code: {
|
||||
...args.code.host === undefined ? {} : { host: args.code.host },
|
||||
...args.code.client === undefined ? {} : { client: args.code.client },
|
||||
},
|
||||
})
|
||||
return Promise.resolve({
|
||||
...receipt,
|
||||
pluginId: String(receipt.pluginId),
|
||||
packageId: String(receipt.packageId),
|
||||
})
|
||||
},
|
||||
presentCall: presentDefineCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_run',
|
||||
description:
|
||||
'Activate one exact Package of a dynamic Plugin. Use mode:"run" for the first activation, restarting '
|
||||
+ 'currentPackageId, or rollback. When current exists, use mode:"update" to switch to a different Package, '
|
||||
+ 'even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and '
|
||||
+ 'returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the '
|
||||
+ 'browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after '
|
||||
+ 'complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or '
|
||||
+ 'technical failure is reported through state and steering. After a technical failure, read diagnostics with '
|
||||
+ 'cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after '
|
||||
+ 'the user rejects it.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', required: true, description: 'Stable Plugin ID returned by cordis_define.' },
|
||||
packageId: { type: 'string', required: true, description: 'Exact immutable Package ID to activate under that Plugin.' },
|
||||
mode: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['run', 'update'],
|
||||
description: 'Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => {
|
||||
const status = value.waitingFor.length > 0
|
||||
? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}`
|
||||
: `is running (plugin "${value.pluginName}"`
|
||||
const result = requireJsonObject(value)
|
||||
const pluginId = requireJsonString(result, 'pluginId')
|
||||
const packageId = requireJsonString(result, 'packageId')
|
||||
const pluginRunId = requireJsonString(result, 'pluginRunId')
|
||||
return [{
|
||||
type: 'text',
|
||||
text: `Temporary Plugin ${value.id} ${status}; available until unmounted or DSH restarts).`,
|
||||
text: result.status === 'awaiting-approval'
|
||||
? `${pluginId}/${packageId} is awaiting user approval (${pluginRunId}).`
|
||||
: result.status === 'starting'
|
||||
? `${pluginId}/${packageId} is starting asynchronously (${pluginRunId}).`
|
||||
: `${pluginId}/${packageId} is running (${pluginRunId}).`,
|
||||
}]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
const id = `dyn-${nextId++}`
|
||||
const sandbox = createSandbox(id)
|
||||
const evaluated = await evaluateMountCode(sandbox, args.code, id, vmTimeoutMs)
|
||||
if (!isPlugin(evaluated)) {
|
||||
if (evaluated === undefined) {
|
||||
throw new Error(
|
||||
'temporary Plugin code returned `undefined` — did you forget `return`?\n'
|
||||
+ ' ✓ return (ctx) => { … }\n'
|
||||
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }',
|
||||
)
|
||||
presentationMeta: (_args, value) => {
|
||||
const result = requireJsonObject(value)
|
||||
return {
|
||||
pluginId: requireJsonString(result, 'pluginId'),
|
||||
packageId: requireJsonString(result, 'packageId'),
|
||||
pluginRunId: requireJsonString(result, 'pluginRunId'),
|
||||
}
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const agent = requireAgent(exec)
|
||||
const pluginId = CordisDynamicPluginId(args.pluginId)
|
||||
const packageId = CordisDynamicPackageId(args.packageId)
|
||||
const receipt = await ctx.dynamicCordisRunner.run(agent, pluginId, packageId, args.mode, exec.signal)
|
||||
if (!receipt.ok) throw new Error(receipt.message)
|
||||
if (receipt.status !== 'running') {
|
||||
return {
|
||||
status: receipt.status,
|
||||
pluginId: args.pluginId,
|
||||
packageId: args.packageId,
|
||||
pluginRunId: String(receipt.pluginRunId),
|
||||
mode: receipt.mode,
|
||||
...receipt.currentPackageId === undefined ? {} : { currentPackageId: String(receipt.currentPackageId) },
|
||||
nextPackageId: String(receipt.nextPackageId),
|
||||
}
|
||||
throw new Error(
|
||||
'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method',
|
||||
)
|
||||
}
|
||||
const fiber = await mountDynamic(group, evaluated)
|
||||
mounts.set(id, { fiber, pluginName: pluginName(evaluated) })
|
||||
// A settled fiber that is not ACTIVE is waiting on unsatisfied inject —
|
||||
// legal cordis semantics (it activates when the service appears), so keep
|
||||
// it mounted but tell the model what it is waiting for.
|
||||
const missing = missingServices(ctx, fiber)
|
||||
const state = STATE_LABELS[fiber.state]
|
||||
const row = ctx.dynamicCordisRunner.snapshot(agent).find(candidate => candidate.pluginId === pluginId)
|
||||
const fiber = row?.activeRun?.pluginRunId === receipt.pluginRunId ? row.activeRun.fiber : undefined
|
||||
return {
|
||||
id,
|
||||
pluginName: pluginName(evaluated),
|
||||
state,
|
||||
provides: providedServices(ctx, fiber),
|
||||
waitingFor: missing,
|
||||
status: 'running',
|
||||
pluginId: args.pluginId,
|
||||
packageId: args.packageId,
|
||||
pluginRunId: String(receipt.pluginRunId),
|
||||
currentPackageId: String(receipt.currentPackageId),
|
||||
...receipt.nextPackageId === undefined ? {} : { nextPackageId: String(receipt.nextPackageId) },
|
||||
host: {
|
||||
status: fiber === undefined ? 'absent' : missingServices(ctx, fiber).length === 0 ? 'running' : 'waiting',
|
||||
provides: fiber === undefined ? [] : providedServices(ctx, fiber),
|
||||
waitingFor: fiber === undefined ? [] : missingServices(ctx, fiber),
|
||||
},
|
||||
client: {
|
||||
status: receipt.clientWaitingFor === undefined
|
||||
? 'absent'
|
||||
: receipt.clientWaitingFor.length === 0 ? 'running' : 'waiting',
|
||||
waitingFor: [...(receipt.clientWaitingFor ?? [])],
|
||||
},
|
||||
}
|
||||
},
|
||||
presentCall: presentMountCall,
|
||||
presentCall: presentRunCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_unmount',
|
||||
name: 'cordis_stop',
|
||||
description:
|
||||
'Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. '
|
||||
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.',
|
||||
'Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the '
|
||||
+ 'Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update '
|
||||
+ 'directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects '
|
||||
+ 'temporarily; use cordis_undefine for permanent removal.',
|
||||
parameters: {
|
||||
id: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart.',
|
||||
},
|
||||
pluginId: { type: 'string', required: true, description: 'Stable dynamic Plugin ID to stop.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'object', additionalProperties: false, properties: { pluginId: { type: 'string', required: true } } },
|
||||
render: (_args, value) => [{ type: 'text', text: `Dynamic Plugin ${value.pluginId} is stopped; its definition and versions remain.` }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const receipt = await ctx.dynamicCordisRunner.stop(requireAgent(exec), CordisDynamicPluginId(args.pluginId))
|
||||
if (!receipt.ok && receipt.reason !== 'not-running') throw new Error(receipt.message)
|
||||
return { pluginId: args.pluginId }
|
||||
},
|
||||
presentCall: presentStopCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_undefine',
|
||||
description:
|
||||
'Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, '
|
||||
+ 'first stop it and cancel the request, then delete every Package, grant, and version pointer. After this '
|
||||
+ 'returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards '
|
||||
+ 'retain only a "Plugin removed" record. Do not call this Tool when versions must remain available for restart '
|
||||
+ 'or rollback; use cordis_stop instead.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', required: true, description: 'Stable dynamic Plugin ID to remove permanently.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
pluginName: { type: 'string', required: true },
|
||||
pluginId: { type: 'string', required: true },
|
||||
wasRunning: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was unmounted and removed.` }],
|
||||
render: (_args, value) => [{ type: 'text', text: `Removed dynamic Plugin ${value.pluginId} and all of its Packages.` }],
|
||||
},
|
||||
async execute(args) {
|
||||
const mount = mounts.get(args.id)
|
||||
if (!mount) {
|
||||
throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`)
|
||||
}
|
||||
await mount.fiber.dispose()
|
||||
mounts.delete(args.id)
|
||||
return { id: args.id, pluginName: mount.pluginName }
|
||||
async execute(args, exec) {
|
||||
const receipt = await ctx.dynamicCordisRunner.undefine(requireAgent(exec), CordisDynamicPluginId(args.pluginId))
|
||||
if (!receipt.ok) throw new Error(receipt.message)
|
||||
return { pluginId: args.pluginId, wasRunning: receipt.wasRunning }
|
||||
},
|
||||
presentCall: presentUnmountCall,
|
||||
presentCall: presentUndefineCall,
|
||||
}))
|
||||
|
||||
ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
const ids = referencedPluginIds(messages)
|
||||
if (ids.length === 0) return decision
|
||||
signal.throwIfAborted()
|
||||
const contexts = ids.map((id) => {
|
||||
const reference = ctx.dynamicCordisRunner.reference(agent, CordisDynamicPluginId(id))
|
||||
return createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: reference === undefined ? renderUnavailableReference(id) : renderReference(reference),
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: name, form: 'instructions' },
|
||||
})
|
||||
})
|
||||
return { kind: 'enter', messages: [...decision.messages, ...contexts] }
|
||||
})
|
||||
}
|
||||
|
||||
function requireJsonObject(value: JsonValue): Record<string, JsonValue> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('expected a JSON object')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function requireJsonString(value: Record<string, JsonValue>, key: string): string {
|
||||
const field = value[key]
|
||||
if (typeof field !== 'string') throw new Error(`expected JSON string field "${key}"`)
|
||||
return field
|
||||
}
|
||||
|
||||
type SelfState = 'defined' | 'awaiting-approval' | 'client-pending' | 'stopped' | 'running' | 'waiting' | 'failed'
|
||||
|
||||
function selfSummary(reference: DynamicCordisReference & { packages?: readonly unknown[] }): Record<string, JsonValue> {
|
||||
const latest = reference.latestRun
|
||||
const state = selfState(reference)
|
||||
return {
|
||||
pluginId: String(reference.pluginId),
|
||||
name: reference.name,
|
||||
packageCount: reference.packages?.length ?? 1,
|
||||
state,
|
||||
...reference.currentPackageId === undefined ? {} : { currentPackageId: String(reference.currentPackageId) },
|
||||
...reference.nextPackageId === undefined ? {} : { nextPackageId: String(reference.nextPackageId) },
|
||||
...reference.activeRun === undefined ? {} : {
|
||||
activeRun: {
|
||||
pluginRunId: String(reference.activeRun.pluginRunId),
|
||||
packageId: String(reference.activeRun.packageId),
|
||||
},
|
||||
},
|
||||
...latest?.status !== 'awaiting-approval' ? {} : {
|
||||
pendingApproval: {
|
||||
pluginRunId: String(latest.pluginRunId),
|
||||
packageId: String(latest.packageId),
|
||||
mode: latest.mode,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function selfState(reference: DynamicCordisReference): SelfState {
|
||||
const status = reference.latestRun?.status
|
||||
if (status === 'awaiting-approval') return 'awaiting-approval'
|
||||
if (status === 'client-pending' || status === 'starting-host') return 'client-pending'
|
||||
if (status === 'failed' || status === 'rejected' || status === 'cancelled') return 'failed'
|
||||
if (status === 'waiting') return 'waiting'
|
||||
if (status === 'running') return 'running'
|
||||
if (reference.activeRun !== undefined) return 'running'
|
||||
return reference.currentPackageId === undefined ? 'defined' : 'stopped'
|
||||
}
|
||||
|
||||
function inspectSelfPackage(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
pluginId: ReturnType<typeof CordisDynamicPluginId>,
|
||||
packageId: ReturnType<typeof CordisDynamicPackageId>,
|
||||
): Record<string, JsonValue> {
|
||||
const inspected = ctx.dynamicCordisRunner.inspectPackage(agent, pluginId, packageId)
|
||||
const row = ctx.dynamicCordisRunner.snapshot(agent).find(candidate => candidate.pluginId === pluginId)
|
||||
const pkg = row?.packages.find(candidate => candidate.packageId === packageId)
|
||||
const active = row?.activeRun?.packageId === packageId ? row.activeRun : undefined
|
||||
const latest = inspected.latestRun?.packageId === packageId ? inspected.latestRun : undefined
|
||||
const hostWaiting = active?.fiber === undefined ? [...(latest?.host.waitingFor ?? [])] : missingServices(ctx, active.fiber)
|
||||
const hostStatus = pkg?.hasHostHalf !== true
|
||||
? 'absent'
|
||||
: latest?.host.status ?? (active === undefined ? 'stopped' : hostWaiting.length === 0 ? 'running' : 'waiting')
|
||||
const clientStatus = pkg?.hasClientHalf !== true
|
||||
? 'absent'
|
||||
: latest?.client.status ?? 'stopped'
|
||||
return {
|
||||
mode: 'package',
|
||||
plugin: selfSummary(inspected),
|
||||
packageId: String(packageId),
|
||||
name: inspected.name,
|
||||
purpose: inspected.purpose,
|
||||
code: inspected.code,
|
||||
runtime: {
|
||||
state: selfState(inspected),
|
||||
host: {
|
||||
status: hostStatus,
|
||||
provides: active?.fiber === undefined ? [] : providedServices(ctx, active.fiber),
|
||||
waitingFor: hostWaiting,
|
||||
handlers: active?.handlers ?? [],
|
||||
...latest?.host.error === undefined ? {} : { error: latest.host.error },
|
||||
},
|
||||
client: {
|
||||
status: clientStatus,
|
||||
waitingFor: [...(latest?.client.waitingFor ?? [])],
|
||||
...latest?.client.error === undefined ? {} : { error: latest.client.error },
|
||||
...active?.renderFailure === undefined ? {} : { renderFailure: active.renderFailure },
|
||||
},
|
||||
},
|
||||
} as unknown as Record<string, JsonValue>
|
||||
}
|
||||
|
||||
function referencedPluginIds(messages: readonly UserMessage[]): string[] {
|
||||
const found = new Set<string>()
|
||||
const pattern = /(?:^|\s)@([a-z]{3,6}-\d+)(?=\s|$)/g
|
||||
for (const message of messages) {
|
||||
if (message.source.kind !== 'user') continue
|
||||
const text = message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
|
||||
for (const match of text.matchAll(pattern)) if (match[1] !== undefined) found.add(match[1])
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
function renderReference(reference: ReturnType<Context['dynamicCordisRunner']['reference']> & {}): string {
|
||||
const mode = reference.currentPackageId === undefined ? 'run' : 'update'
|
||||
return [
|
||||
'<cordis_dynamic_plugin_context>',
|
||||
JSON.stringify(reference, null, 2),
|
||||
'',
|
||||
`The user explicitly referenced @${reference.pluginId}. Use Package ${reference.packageId} as the base for this modification.`,
|
||||
`Before modifying it, call cordis_inspect_self with pluginId="${reference.pluginId}" and packageId="${reference.packageId}" to read the exact metadata and source.`,
|
||||
`Use cordis_define with plugin.kind="existing" and the original pluginId="${reference.pluginId}" to append an immutable Package.`,
|
||||
`Do not create a new Plugin for this request. After cordis_define succeeds, call cordis_run mode="${mode}" with the returned packageId.`,
|
||||
'</cordis_dynamic_plugin_context>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderUnavailableReference(id: string): string {
|
||||
return [
|
||||
'<cordis_dynamic_plugin_context>',
|
||||
`The user explicitly referenced @${id}, but this Plugin is unavailable in the current Session.`,
|
||||
'It may have been removed, belong to another Session, or have been lost when the DSH process restarted.',
|
||||
'Do not claim that it was updated or silently create a replacement Plugin. Tell the user that the reference is currently unavailable.',
|
||||
'</cordis_dynamic_plugin_context>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
/**
|
||||
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
|
||||
* plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits),
|
||||
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the
|
||||
* original source JSDoc without inflating the default reports.
|
||||
* Text renderers for `cordis_runtime_inspect`. Live facts come from the service store and
|
||||
* the plugin registry; what each service CAN DO comes from the generated
|
||||
* `api-catalog.ts`. This module owns the join of the two plus presentation: which
|
||||
* lines a section prints, how compact the default report stays, and what an exact
|
||||
* `name` adds.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/inspect
|
||||
*/
|
||||
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
// Type-only: resolves `ctx.dynamicCordisRunner` (the registry this report reads).
|
||||
import type {} from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
|
||||
import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, TypeApiEntry } from './api-catalog.ts'
|
||||
import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, ServiceApiMethod, TypeApiEntry } from './api-catalog.ts'
|
||||
import { CLIENT_NOTES, CLIENT_SLOT_API } from './client-catalog.ts'
|
||||
import type { ClientSlotEntry } from './client-catalog.ts'
|
||||
import { FiberState, STATE_LABELS } from './fiber-state.ts'
|
||||
import { missingServices } from './mount.ts'
|
||||
import type { DynamicMount } from './mount.ts'
|
||||
|
||||
/** The live service registrations from `ctx.reflect.store` (map + filter keeps the possibly-undefined index read branch-free). */
|
||||
/** One live service joined with what the generated catalog knows about it. */
|
||||
interface LiveService {
|
||||
/** The `ctx.<name>` key. */
|
||||
name: string
|
||||
/** Plugin fiber providing it. */
|
||||
owner: string
|
||||
/** Lifecycle state of that fiber; `active` while it is serving. */
|
||||
state: string
|
||||
/** First sentence of the catalog summary; empty when the catalog has no entry. */
|
||||
summary: string
|
||||
/** Whether the generated catalog carries signatures for it. */
|
||||
catalogued: boolean
|
||||
/** Public method signatures from the catalog, empty for an uncatalogued service. */
|
||||
methods: readonly string[]
|
||||
}
|
||||
|
||||
/** The live service registrations, read from the reflect store. */
|
||||
function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
|
||||
const store = ctx.reflect.store
|
||||
return Object.getOwnPropertySymbols(store)
|
||||
@@ -22,8 +42,52 @@ function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
|
||||
.filter((impl): impl is NonNullable<typeof impl> => impl !== undefined)
|
||||
}
|
||||
|
||||
/** Whether `fiber` is `root` itself or mounted anywhere inside `root`'s subtree. */
|
||||
function withinFiber(fiber: Fiber, root: Fiber): boolean {
|
||||
/**
|
||||
* A summary as prose. JSDoc may name a symbol with an inline `{@link Foo.bar}`
|
||||
* tag, which the generated catalog retains verbatim; a report is read, not
|
||||
* compiled, so the link syntax is spent context and the bare symbol says the same
|
||||
* thing.
|
||||
*/
|
||||
function plainSummary(summary: string): string {
|
||||
return summary.replace(/\{@link\s+([^}]+)\}/g, '$1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every service this process provides, joined with the generated catalog: what is
|
||||
* RUNNING comes from the store, what each service CAN DO comes from the catalog,
|
||||
* and a live service the catalog does not cover stays in the list as reachable
|
||||
* with no signatures rather than being dropped.
|
||||
*/
|
||||
function liveServices(ctx: Context, api: readonly ServiceApiEntry[]): LiveService[] {
|
||||
const catalogued = new Map(api.map(entry => [entry.key, entry]))
|
||||
return liveImpls(ctx)
|
||||
.map((impl) => {
|
||||
const entry = catalogued.get(impl.name)
|
||||
return {
|
||||
name: impl.name,
|
||||
owner: impl.fiber.name,
|
||||
state: STATE_LABELS[impl.fiber.state],
|
||||
summary: entry === undefined ? '' : plainSummary(entry.summary),
|
||||
catalogued: entry !== undefined,
|
||||
methods: entry === undefined ? [] : entry.methods.map(method => method.signature),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/** Catalogued services with no live provider: loadable in principle, absent here. */
|
||||
function absentServices(ctx: Context, api: readonly ServiceApiEntry[]): string[] {
|
||||
const live = new Set(liveImpls(ctx).map(impl => impl.name))
|
||||
return api.filter(entry => !live.has(entry.key)).map(entry => entry.key).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fiber is `root` itself or mounted anywhere inside `root`'s subtree.
|
||||
* @param fiber - the fiber to locate.
|
||||
* @param root - the subtree root to test against.
|
||||
* @returns true when `fiber` belongs to that subtree.
|
||||
*/
|
||||
export function withinFiber(fiber: Fiber, root: Fiber): boolean {
|
||||
let current = fiber
|
||||
while (true) {
|
||||
if (current === root) return true
|
||||
@@ -34,7 +98,7 @@ function withinFiber(fiber: Fiber, root: Fiber): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the service names provided by a mount's fiber subtree.
|
||||
* Service names provided by one 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.
|
||||
@@ -47,24 +111,40 @@ export function providedServices(ctx: Context, fiber: Fiber): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `services` section: every provided ctx service with its owning fiber,
|
||||
* annotating non-active owners with their lifecycle state.
|
||||
* @param ctx - the runtime to enumerate.
|
||||
* @returns one line per service, or a single placeholder line when none are provided.
|
||||
* Services a fiber declared in `inject` that do not exist yet — a settled fiber
|
||||
* that is not active is waiting on exactly these (legal cordis semantics: it
|
||||
* activates when the service appears).
|
||||
* @param ctx - the context to resolve service existence against.
|
||||
* @param fiber - the fiber whose `inject` declarations are checked.
|
||||
* @returns the missing service names, in declaration order.
|
||||
*/
|
||||
export function describeServices(ctx: Context): string[] {
|
||||
const lines = liveImpls(ctx).map((impl) => {
|
||||
const active = impl.fiber.state === FiberState.ACTIVE
|
||||
return `- ${impl.name} (provided by ${impl.fiber.name}${active ? '' : `, ${STATE_LABELS[impl.fiber.state]}`})`
|
||||
})
|
||||
return lines.length > 0 ? lines : ['(no services provided)']
|
||||
export function missingServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `plugins` section: a flat list of every fiber the registry knows, one
|
||||
* line per fiber with its lifecycle state, sorted by plugin name (a plugin
|
||||
* mounted more than once repeats — one line per instance). Temporary plugins are
|
||||
* listed like any other plugin; their ids live in the `temporary` section.
|
||||
* The `services` section: every live ctx service with its owning fiber and, when
|
||||
* the generated catalog covers it, a one-line summary. The `api` section is the
|
||||
* one that carries signatures; this one answers what exists and who provides it.
|
||||
* @param ctx - the runtime to enumerate.
|
||||
* @param api - the generated service entries whose summaries annotate the live ones.
|
||||
* @returns one line per service, or a single placeholder line when none are provided.
|
||||
*/
|
||||
export function describeServices(ctx: Context, api: readonly ServiceApiEntry[] = SERVICE_API): string[] {
|
||||
const live = liveServices(ctx, api)
|
||||
if (live.length === 0) return ['(no services provided)']
|
||||
return live.map((service) => {
|
||||
const state = service.state === STATE_LABELS[FiberState.ACTIVE] ? '' : `, ${service.state}`
|
||||
const summary = service.summary === '' ? '' : ` — ${service.summary}`
|
||||
return `- ${service.name} (provided by ${service.owner}${state})${summary}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `plugins` section: a flat list of every fiber the registry knows, one line
|
||||
* per fiber with its lifecycle state, sorted by plugin name (a plugin mounted
|
||||
* more than once repeats — one line per instance). Temporary plugins are listed
|
||||
* like any other plugin; their ids live in the `temporary` section.
|
||||
* @param ctx - the runtime whose registry is enumerated.
|
||||
* @returns one line per loaded plugin fiber.
|
||||
*/
|
||||
@@ -74,7 +154,7 @@ export function describePlugins(ctx: Context): string[] {
|
||||
for (const fiber of runtime.fibers) fibers.push(fiber)
|
||||
}
|
||||
return fibers
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
|
||||
}
|
||||
|
||||
@@ -91,22 +171,42 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle
|
||||
* state, the services its subtree provides, and — for a pending mount — the
|
||||
* services it waits for.
|
||||
* @param ctx - the runtime the mounts live in.
|
||||
* @param mounts - the tracked mounts, in mount order.
|
||||
* @returns one line per mount, or a single placeholder line when none exist.
|
||||
* The `temporary` section: one line per dynamic package this session defined,
|
||||
* with its metadata, which halves exist, the host half's lifecycle state and
|
||||
* provides/waits, the invoke methods it registered, and the last browser-half
|
||||
* load report. Session-scoped like every runner verb.
|
||||
* @param ctx - the runtime the packages live in.
|
||||
* @param agent - the calling agent; without one there is no definition space to report.
|
||||
* @returns one line per package, or a single placeholder line when none exist.
|
||||
*/
|
||||
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
|
||||
if (mounts.size === 0) {
|
||||
return ['No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.']
|
||||
export function describeDynamic(ctx: Context, agent?: Agent): string[] {
|
||||
const rows = agent === undefined ? [] : ctx.dynamicCordisRunner.snapshot(agent)
|
||||
if (rows.length === 0) {
|
||||
return ['No dynamic Plugins are defined in this session. Definitions live only in this process\'s memory, so a DSH restart clears them.']
|
||||
}
|
||||
return [...mounts].map(([id, mount]) => {
|
||||
const provides = providedServices(ctx, mount.fiber)
|
||||
const waiting = missingServices(ctx, mount.fiber)
|
||||
const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state]
|
||||
return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until unmounted or DSH restarts`
|
||||
return rows.flatMap((row) => {
|
||||
const head = `- Plugin ${row.pluginId}; current: ${row.currentPackageId ?? 'none'}; next: ${row.nextPackageId ?? 'none'}`
|
||||
+ (row.activeRun === undefined
|
||||
? '; stopped'
|
||||
: `; active: ${row.activeRun.packageId} as ${row.activeRun.pluginRunId}`)
|
||||
const packages = row.packages.map((pkg) => {
|
||||
const halves = [...pkg.hasHostHalf ? ['host'] : [], ...pkg.hasClientHalf ? ['client'] : []].join('+')
|
||||
const active = row.activeRun?.packageId === pkg.packageId ? row.activeRun : undefined
|
||||
if (active === undefined) return ` - ${pkg.packageId}: ${pkg.name} (${halves}) — ${pkg.purpose}`
|
||||
const fiber = active.fiber
|
||||
const state = fiber === undefined ? 'running' : fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[fiber.state]
|
||||
const provides = fiber === undefined ? [] : providedServices(ctx, fiber)
|
||||
const waiting = fiber === undefined ? [] : missingServices(ctx, fiber)
|
||||
const failure = active.renderFailure
|
||||
const rendered = failure === undefined
|
||||
? ''
|
||||
: `; CLIENT RENDER FAILED at ${failure.slot}: ${failure.message}${failure.abdicated ? ' (entry removed)' : ''}`
|
||||
return ` - ${pkg.packageId}: ${pkg.name} [${state}, ${active.pluginRunId}] (${halves}) — ${pkg.purpose}`
|
||||
+ `; provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}`
|
||||
+ (active.handlers.length === 0 ? '' : `; host methods: ${active.handlers.join(', ')}`)
|
||||
+ rendered
|
||||
})
|
||||
return [head, ...packages]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,17 +230,24 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return [...included.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
return [...included.values()].sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/** Render one catalogued service, optionally including source-owned method JSDoc. */
|
||||
function serviceLines(entry: ServiceApiEntry, detailed: boolean): string[] {
|
||||
const lines = [`- ${entry.key} — ${entry.summary}`]
|
||||
for (const method of entry.methods) {
|
||||
if (detailed) {
|
||||
for (const docLine of method.jsDoc.split('\n')) lines.push(` ${docLine}`)
|
||||
/** Render one live catalogued service; `documented` is non-empty only for an exact-name report. */
|
||||
function serviceLines(
|
||||
service: LiveService,
|
||||
documented: readonly ServiceApiMethod[],
|
||||
): string[] {
|
||||
const lines = [`- ${service.name} — ${service.summary}`]
|
||||
for (const signature of service.methods) {
|
||||
const contract = documented.find(entry => entry.signature === signature)
|
||||
if (contract !== undefined) {
|
||||
lines.push(` ${contract.description}`)
|
||||
for (const parameter of contract.parameters) lines.push(` @param ${parameter.name} — ${parameter.description}`)
|
||||
if (contract.returns !== undefined) lines.push(` @returns ${contract.returns}`)
|
||||
for (const failure of contract.throws ?? []) lines.push(` @throws ${failure}`)
|
||||
}
|
||||
lines.push(` ${method.signature}`)
|
||||
lines.push(` ${signature}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -151,44 +258,41 @@ function serviceLines(entry: ServiceApiEntry, detailed: boolean): string[] {
|
||||
* inherited Context APIs.
|
||||
* @param ctx - the runtime to intersect the catalog with.
|
||||
* @param api - generated service entries, replaceable in tests.
|
||||
* @param name - exact live service key whose methods should include structured contracts; omitted for the compact catalog.
|
||||
* @param inherited - inherited `ctx` entries, replaceable in tests.
|
||||
* @param types - public type shapes, replaceable in tests.
|
||||
* @param name - exact live service key whose methods should include original JSDoc; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeApi(
|
||||
ctx: Context,
|
||||
api: readonly ServiceApiEntry[] = SERVICE_API,
|
||||
name?: string,
|
||||
inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
|
||||
types: readonly TypeApiEntry[] = TYPE_API,
|
||||
name?: string,
|
||||
): string[] {
|
||||
const live = new Map<string, string>()
|
||||
for (const impl of liveImpls(ctx)) live.set(impl.name, impl.fiber.name)
|
||||
const live = liveServices(ctx, api)
|
||||
const byKey = new Map(api.map(entry => [entry.key, entry]))
|
||||
const lines: string[] = []
|
||||
const liveMethodTexts: string[] = []
|
||||
let selected = api.filter(entry => live.has(entry.key))
|
||||
let selected = live.filter(service => service.catalogued)
|
||||
let documented: readonly ServiceApiMethod[] = []
|
||||
if (name !== undefined) {
|
||||
const entry = api.find(candidate => candidate.key === name)
|
||||
if (!entry) throw new Error(`no catalogued service named "${name}"`)
|
||||
if (!live.has(name)) throw new Error(`catalogued service "${name}" is not running`)
|
||||
selected = [entry]
|
||||
}
|
||||
for (const entry of selected) {
|
||||
lines.push(...serviceLines(entry, name !== undefined))
|
||||
for (const method of entry.methods) {
|
||||
liveMethodTexts.push(method.signature)
|
||||
}
|
||||
const entry = byKey.get(name)
|
||||
if (entry === undefined) throw new Error(`no catalogued service named "${name}"`)
|
||||
const service = live.find(candidate => candidate.name === name)
|
||||
if (service === undefined) throw new Error(`catalogued service "${name}" is not running`)
|
||||
selected = [service]
|
||||
documented = entry.methods
|
||||
}
|
||||
for (const service of selected) lines.push(...serviceLines(service, documented))
|
||||
if (name === undefined) {
|
||||
const catalogued = new Set(api.map(entry => entry.key))
|
||||
for (const [liveName, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (!catalogued.has(liveName)) lines.push(`- ${liveName} (provided by ${fiber}, no catalog entry)`)
|
||||
for (const service of live.filter(candidate => !candidate.catalogued)) {
|
||||
lines.push(`- ${service.name} (provided by ${service.owner}) — running, but this catalog has no signature for it;`
|
||||
+ ` inject: ['${service.name}'] still reaches it`)
|
||||
}
|
||||
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
|
||||
const notRunning = absentServices(ctx, api)
|
||||
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
|
||||
}
|
||||
const shapes = typeClosure(liveMethodTexts, types)
|
||||
const shapes = typeClosure(selected.flatMap(service => [...service.methods]), types)
|
||||
if (shapes.length > 0) {
|
||||
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
|
||||
for (const shape of shapes) {
|
||||
@@ -206,7 +310,7 @@ export function describeApi(
|
||||
* The `events` section: every harness event with its dispatch mode, one-line
|
||||
* summary, and exact signature, closed by the waterfall caution.
|
||||
* @param events - the event catalog (the generated one by default; injectable for tests).
|
||||
* @param name - exact event name whose signature should include original JSDoc; omitted for the compact catalog.
|
||||
* @param name - exact event name whose signature should include its structured contract; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] {
|
||||
@@ -219,7 +323,8 @@ export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, nam
|
||||
const lines = selected.flatMap((event) => {
|
||||
const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`]
|
||||
if (name !== undefined) {
|
||||
for (const docLine of event.jsDoc.split('\n')) entry.push(` ${docLine}`)
|
||||
entry.push(` ${event.description}`)
|
||||
for (const parameter of event.parameters) entry.push(` @param ${parameter.name} — ${parameter.description}`)
|
||||
}
|
||||
entry.push(` ${event.signature}`)
|
||||
return entry
|
||||
@@ -227,3 +332,72 @@ export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, nam
|
||||
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.')
|
||||
return lines
|
||||
}
|
||||
|
||||
/** One slot's compact listing row: what it is, and whether registering costs shipped UI. */
|
||||
function slotSummaryLines(entry: ClientSlotEntry): string[] {
|
||||
const lines = [`- ${entry.key} [${entry.kind}, ${entry.scope}] — ${entry.summary}`]
|
||||
lines.push(entry.replaceRisk === 'shadows-shipped-ui'
|
||||
? ` OCCUPIED — registering here REPLACES: ${entry.occupants.join('; ')}`
|
||||
: ` additive${entry.occupants.length === 0 ? ' (no shipped entries)' : ` (beside: ${entry.occupants.join('; ')})`}`)
|
||||
return lines
|
||||
}
|
||||
|
||||
/** One slot's full teaching block: how to register, what arrives, what it costs. */
|
||||
function slotDetailLines(entry: ClientSlotEntry): string[] {
|
||||
const lines = [`- ${entry.key} [${entry.kind}, ${entry.scope}]`]
|
||||
for (const docLine of entry.doc.split('\n')) lines.push(` ${docLine}`)
|
||||
lines.push(` exists: ${entry.declaredBy}`)
|
||||
lines.push(entry.replaceRisk === 'shadows-shipped-ui'
|
||||
? ` OCCUPIED — registering here REPLACES: ${entry.occupants.join('; ')}`
|
||||
: ` additive${entry.occupants.length === 0 ? ' (no shipped entries)' : ` (beside: ${entry.occupants.join('; ')})`}`)
|
||||
if (entry.keyDomain !== '') lines.push(` key domain: ${entry.keyDomain}`)
|
||||
lines.push(` register options besides name:${entry.registerOptions.length === 0 ? ' none' : ''}`)
|
||||
for (const option of entry.registerOptions) {
|
||||
lines.push(` ${option.name} (${option.requirement}, ${option.type}) — ${option.doc}`)
|
||||
}
|
||||
lines.push(entry.ownerProps.length === 0
|
||||
? ' owner props: none — the owner supplies only the render site'
|
||||
: ' owner props (the shapes the owner passes down):')
|
||||
for (const declaration of entry.ownerProps) {
|
||||
for (const declLine of declaration.split('\n')) lines.push(` ${declLine}`)
|
||||
}
|
||||
if (entry.ownerPropsReferences.length > 0) {
|
||||
lines.push(` shapes those fields reference, not expanded here: ${entry.ownerPropsReferences.join(', ')}`
|
||||
+ ' — read the field as the contract above describes it rather than the whole shape')
|
||||
}
|
||||
lines.push(' framework props for this scope:')
|
||||
for (const prop of entry.standardProps) lines.push(` ${prop}`)
|
||||
if (entry.slotInject !== '') lines.push(` slot-level inject face every entry receives: ${entry.slotInject}`)
|
||||
if (entry.hookContext !== '') lines.push(` per-render-site hook context: ${entry.hookContext}`)
|
||||
lines.push(' minimal browser half:')
|
||||
for (const codeLine of entry.example.split('\n')) lines.push(` ${codeLine}`)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The `client` section: the browser half's slot surface — every seat a dynamic
|
||||
* package can contribute UI into, whether taking it costs shipped UI, and (with
|
||||
* an exact `name`) the full register contract for one seat. Compile-time data
|
||||
* from the shipped web bundle, so it needs no live runtime.
|
||||
* @param slots - the generated slot catalog (injectable for tests).
|
||||
* @param notes - the cross-cutting registrant rules (injectable for tests).
|
||||
* @param name - exact slot key to expand; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
* @throws when `name` is not a catalogued slot key.
|
||||
*/
|
||||
export function describeClient(
|
||||
slots: readonly ClientSlotEntry[] = CLIENT_SLOT_API,
|
||||
notes: readonly string[] = CLIENT_NOTES,
|
||||
name?: string,
|
||||
): string[] {
|
||||
if (name !== undefined) {
|
||||
const entry = slots.find(candidate => candidate.key === name)
|
||||
if (!entry) throw new Error(`no catalogued client slot named "${name}"`)
|
||||
return slotDetailLines(entry)
|
||||
}
|
||||
const lines = slots.flatMap(entry => slotSummaryLines(entry))
|
||||
lines.push('how to contribute:')
|
||||
for (const note of notes) lines.push(`- ${note}`)
|
||||
lines.push('pass name:"<slot key>" with what:"client" for one slot\'s register options, owner/framework props, and a minimal browser half.')
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Dynamic-mount lifecycle over the `cordis-dynamic` group fiber: settle a
|
||||
* sandbox-produced plugin as a child fiber (never leaving a failed fiber
|
||||
* mounted), and report the services a settled-but-pending fiber still waits
|
||||
* for. Disposal needs no helper — a mount unwinds through an ordinary awaited
|
||||
* `fiber.dispose()`, because everything the plugin registered is an effect on
|
||||
* its fiber.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-cordis/mount
|
||||
*/
|
||||
|
||||
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
|
||||
import { guardedPlugin } from './guard.ts'
|
||||
|
||||
/** One tracked dynamic mount: the fiber plus the display name captured at mount time. */
|
||||
export interface DynamicMount {
|
||||
/** The child fiber under the `cordis-dynamic` group. */
|
||||
fiber: Fiber
|
||||
/** The plugin's display name at mount time (its `name`, else `<anonymous>`). */
|
||||
pluginName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Await the group, mount and settle one guarded child, and dispose it before rethrowing any
|
||||
* startup failure so a failed mount never lingers. A valid unresolved inject may remain pending.
|
||||
* @param group - the `cordis-dynamic` group fiber every mount hangs under.
|
||||
* @param plugin - the plugin the sandbox returned; wrapped with the registration guard before mounting.
|
||||
* @returns the settled child fiber (possibly pending on unsatisfied `inject`).
|
||||
*/
|
||||
export async function mountDynamic(group: Fiber, plugin: Plugin): Promise<Fiber> {
|
||||
await group.await()
|
||||
const fiber = group.ctx.plugin(guardedPlugin(plugin))
|
||||
try {
|
||||
await fiber.await()
|
||||
} catch (error) {
|
||||
await fiber.dispose()
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// The commonest startup collision is remounting a NEW version of a tool
|
||||
// while the old mount still holds the name — teach the replace recipe.
|
||||
if (message.includes('already registered')) {
|
||||
throw new Error(
|
||||
`${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_unmount that Plugin's id `
|
||||
+ '(find it with cordis_inspect what:"temporary"), then mount the new version.',
|
||||
)
|
||||
}
|
||||
throw error instanceof Error ? error : new Error(message)
|
||||
}
|
||||
return fiber
|
||||
}
|
||||
|
||||
/**
|
||||
* The services a fiber declared in `inject` that do not exist yet — a settled
|
||||
* fiber that is not active is waiting on exactly these (legal cordis
|
||||
* semantics: it activates when the service appears).
|
||||
* @param ctx - the context to resolve service existence against.
|
||||
* @param fiber - the mount fiber whose `inject` declarations are checked.
|
||||
* @returns the missing service names, in declaration order.
|
||||
*/
|
||||
export function missingServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
}
|
||||
@@ -1,52 +1,67 @@
|
||||
/**
|
||||
* UI render intents for the three cordis tools — all `generic` cards, decided
|
||||
* up front as part of the tool design. Presenters are pure functions of the
|
||||
* call arguments (they run on replay too): no I/O, no session state, no clock.
|
||||
* No `presentResult` overrides exist — the tools' text results are their
|
||||
* correct completed rendering.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-cordis/present
|
||||
*/
|
||||
/** Pure replay-safe render intents for Cordis tools. */
|
||||
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* The `cordis_inspect` call card: a read, titled with the requested section.
|
||||
* @param args - the validated call arguments.
|
||||
* @returns the generic call card.
|
||||
*/
|
||||
export function presentInspectCall(args: { what?: string; name?: string }): GenericCallView {
|
||||
/** Render a runtime-inspection call. */
|
||||
export function presentRuntimeInspectCall(args: { what?: string; name?: string }): GenericCallView {
|
||||
const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: target === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${target}`,
|
||||
}
|
||||
return { card: 'generic', kind: 'read', title: target === undefined ? 'Inspect Cordis runtime' : `Inspect Cordis runtime: ${target}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `cordis_mount` call card: an execute carrying the temporary-plugin code as raw input.
|
||||
* @param args - the validated call arguments.
|
||||
* @returns the generic call card.
|
||||
*/
|
||||
export function presentMountCall(args: { code: string }): GenericCallView {
|
||||
/** Render provider-directory inspection. */
|
||||
export function presentInspectListCall(): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: 'List Cordis Inspect Providers' }
|
||||
}
|
||||
|
||||
/** Render one provider query. */
|
||||
export function presentInspectQueryCall(args: { platform: string; provider: string; method: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: `Query Cordis ${args.platform} ${args.provider}.${args.method}` }
|
||||
}
|
||||
|
||||
/** Render layered self-inspection. */
|
||||
export function presentInspectSelfCall(args: { pluginId?: string; packageId?: string }): GenericCallView {
|
||||
const target = args.pluginId === undefined
|
||||
? 'dynamic Cordis Plugins'
|
||||
: args.packageId === undefined ? args.pluginId : `${args.pluginId}/${args.packageId}`
|
||||
return { card: 'generic', kind: 'read', title: `Inspect ${target}` }
|
||||
}
|
||||
|
||||
/** Render an immutable Package source-inspection call. */
|
||||
export function presentPackageInspectCall(args: { pluginId: string; packageId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: `Inspect Cordis Package ${args.pluginId}/${args.packageId}` }
|
||||
}
|
||||
|
||||
/** Render a new or appended Package definition. */
|
||||
export function presentDefineCall(args: {
|
||||
plugin: { kind: 'new'; idPrefix: string } | { kind: 'existing'; pluginId: string }
|
||||
name: string
|
||||
purpose: string
|
||||
code: { host?: string; client?: string }
|
||||
}): GenericCallView {
|
||||
const target = args.plugin.kind === 'new' ? `new ${args.plugin.idPrefix}-*` : args.plugin.pluginId
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: 'Mount temporary Cordis Plugin',
|
||||
rawInput: { code: args.code },
|
||||
title: `Define Package "${args.name}" for ${target}: ${args.purpose}`,
|
||||
rawInput: args.code,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `cordis_unmount` call card: a delete, titled with the temporary-plugin id.
|
||||
* @param args - the validated call arguments.
|
||||
* @returns the generic call card.
|
||||
*/
|
||||
export function presentUnmountCall(args: { id: string }): GenericCallView {
|
||||
/** Render Plugin removal. */
|
||||
export function presentUndefineCall(args: { pluginId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'delete', title: `Remove dynamic Plugin ${args.pluginId}` }
|
||||
}
|
||||
|
||||
/** Render one exact Package activation. */
|
||||
export function presentRunCall(args: { pluginId: string; packageId: string; mode: 'run' | 'update' }): GenericCallView {
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'delete',
|
||||
title: `Unmount temporary Cordis Plugin ${args.id}`,
|
||||
kind: 'execute',
|
||||
title: `${args.mode === 'update' ? 'Update' : 'Run'} ${args.pluginId} · ${args.packageId}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Render Plugin stop. */
|
||||
export function presentStopCall(args: { pluginId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'execute', title: `Stop dynamic Plugin ${args.pluginId}` }
|
||||
}
|
||||
|
||||
102
packages/extensions/tool-cordis/src/prompt.ts
Normal file
102
packages/extensions/tool-cordis/src/prompt.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/** Model guidance shared by the Cordis dynamic-plugin tools. */
|
||||
|
||||
export const CORDIS_SYSTEM_PROMPT = `# Dynamic Cordis Plugins
|
||||
|
||||
Dynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.
|
||||
|
||||
- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.
|
||||
- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.
|
||||
|
||||
## Make the user-facing plan clear first
|
||||
|
||||
- First decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.
|
||||
- If a visual, copy, or interaction choice would materially affect the result, ask at most one concise creative-preference question and offer a few candidate directions. Do not conduct a multi-round interview or a complex questionnaire.
|
||||
- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.
|
||||
- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.
|
||||
- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.
|
||||
- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.
|
||||
|
||||
## Recommended workflow and Tools
|
||||
|
||||
Before creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.
|
||||
|
||||
1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.
|
||||
2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.
|
||||
3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.
|
||||
4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.
|
||||
5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.
|
||||
6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.
|
||||
7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.
|
||||
|
||||
- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.
|
||||
- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.
|
||||
- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.
|
||||
|
||||
## Identity, versions, and approval
|
||||
|
||||
- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.
|
||||
- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.
|
||||
- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.
|
||||
- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.
|
||||
- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.
|
||||
- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.
|
||||
- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.
|
||||
|
||||
When the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:
|
||||
|
||||
1. Call cordis_inspect_self(pluginId, packageId) to read the target source.
|
||||
2. Use cordis_define in existing mode to append a Package to the same Plugin.
|
||||
3. Call cordis_run in run or update mode according to the version relationship.
|
||||
|
||||
Never silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.
|
||||
|
||||
## High-frequency errors that must be avoided
|
||||
|
||||
### Services: ctx.get and inject
|
||||
|
||||
- Read an optional Service with ctx.get('serviceName') by default and handle undefined.
|
||||
- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.
|
||||
- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.
|
||||
|
||||
return {
|
||||
inject: ['requiredService'],
|
||||
apply(ctx) {
|
||||
ctx.requiredService.someMethod()
|
||||
const optionalService = ctx.get('optionalService')
|
||||
if (optionalService !== undefined) optionalService.someMethod()
|
||||
},
|
||||
}
|
||||
|
||||
### Code: use plain JavaScript only
|
||||
|
||||
- Host and Client code is not transformed by TypeScript, JSX, or a bundler.
|
||||
- Do not use TypeScript types, as, decorators, import, require, or JSX.
|
||||
- Client React code must use React.createElement(...); never write <Component />.
|
||||
- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.
|
||||
|
||||
### Data: do not serialize live data
|
||||
|
||||
- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.
|
||||
- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.
|
||||
- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.
|
||||
|
||||
### Lifecycle: every side effect must be reversible
|
||||
|
||||
- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.
|
||||
- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.
|
||||
- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.
|
||||
|
||||
## Host and Client
|
||||
|
||||
- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.
|
||||
- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.
|
||||
- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.
|
||||
- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.
|
||||
- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.
|
||||
|
||||
## Asynchronous results and recovery
|
||||
|
||||
- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.
|
||||
- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.
|
||||
- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.
|
||||
- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.`
|
||||
97
packages/extensions/tool-cordis/src/providers.ts
Normal file
97
packages/extensions/tool-cordis/src/providers.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/** First-party Host inspect providers registered by the Cordis tool package. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { HOST_BUILTIN_INSPECTION } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { HostCordisInspectProviderRegistration } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { EVENT_API, queryEventApi, queryServiceApi } from './api-catalog.ts'
|
||||
|
||||
const EMPTY_INPUT = { type: 'object', properties: {}, additionalProperties: false } as const
|
||||
const ANY_OUTPUT = { description: 'JSON data owned by this inspect provider.' } as const
|
||||
const SERVICE_INPUT = exactInput('service', 'Exact Service key. Omit it for the compact Service and method-signature directory.')
|
||||
const EVENT_INPUT = exactInput('event', 'Exact Event name. Omit it for the compact Event and listener-signature directory.')
|
||||
const SERVICE_OUTPUT = {
|
||||
description: 'Compact Service directory, or one exact Service contract with only its referenced type declarations.',
|
||||
} as const
|
||||
const EVENT_OUTPUT = {
|
||||
description: 'Compact Event directory, or one exact Event contract with only its referenced type declarations.',
|
||||
} as const
|
||||
const HOST_EVENTS = EVENT_API.filter(event => !event.name.startsWith('cordis/'))
|
||||
|
||||
/** Construct Host providers over generated Catalogs, evaluator declarations, and live Tool scope. */
|
||||
export function hostInspectProviders(ctx: Context): HostCordisInspectProviderRegistration[] {
|
||||
return [
|
||||
registration(
|
||||
'Service',
|
||||
'Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.',
|
||||
'listService',
|
||||
async input => queryServiceApi(readExact(input, 'service')) as unknown as JsonValue,
|
||||
SERVICE_INPUT,
|
||||
SERVICE_OUTPUT,
|
||||
),
|
||||
registration(
|
||||
'Event',
|
||||
'Progressive Host Event discovery: compact listener directory, then one exact event contract.',
|
||||
'listEvents',
|
||||
async input => queryEventApi(readExact(input, 'event'), HOST_EVENTS) as unknown as JsonValue,
|
||||
EVENT_INPUT,
|
||||
EVENT_OUTPUT,
|
||||
),
|
||||
registration('Builtin', 'Plain-JavaScript symbols available to a dynamic Host half.', 'listBuiltins', async () => ({
|
||||
builtins: HOST_BUILTIN_INSPECTION,
|
||||
referencedTypes: [],
|
||||
} as unknown as JsonValue)),
|
||||
{
|
||||
manifest: {
|
||||
id: 'Tool',
|
||||
description: 'Tools visible to the requesting Agent, including scoped and dynamic registrations.',
|
||||
methods: [{
|
||||
name: 'listTools',
|
||||
description: 'Return every Tool schema currently callable by this Agent.',
|
||||
inputSchema: EMPTY_INPUT,
|
||||
outputSchema: ANY_OUTPUT,
|
||||
}],
|
||||
},
|
||||
query(method, _input, context) {
|
||||
if (method !== 'listTools') throw new Error(`unknown Tool inspect method "${method}"`)
|
||||
return Promise.resolve({ tools: ctx.tools.schemas(context.agent) } as unknown as JsonValue)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function registration(
|
||||
id: string,
|
||||
description: string,
|
||||
method: string,
|
||||
query: (input: JsonValue | undefined) => Promise<JsonValue>,
|
||||
inputSchema: JsonValue = EMPTY_INPUT,
|
||||
outputSchema: JsonValue = ANY_OUTPUT,
|
||||
): HostCordisInspectProviderRegistration {
|
||||
return {
|
||||
manifest: {
|
||||
id,
|
||||
description,
|
||||
methods: [{
|
||||
name: method,
|
||||
description,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
}],
|
||||
},
|
||||
async query(requested, input) {
|
||||
if (requested !== method) throw new Error(`unknown ${id} inspect method "${requested}"`)
|
||||
return await query(input)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function exactInput(field: string, description: string): JsonValue {
|
||||
return { type: 'object', properties: { [field]: { type: 'string', description } }, additionalProperties: false }
|
||||
}
|
||||
|
||||
function readExact(input: JsonValue | undefined, field: string): string | undefined {
|
||||
if (input === undefined || input === null || Array.isArray(input) || typeof input !== 'object') return undefined
|
||||
const value = input[field]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a
|
||||
* tagged write-through console, the `harness` registration helpers, the encoding primitives a
|
||||
* bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
|
||||
* withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
|
||||
* `ctx.shell`, and Cordis timers. This keeps cooperative mounts inspectable and disposable but
|
||||
* is not containment: host-realm helper functions remain an escape route.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/sandbox
|
||||
*/
|
||||
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
|
||||
|
||||
/**
|
||||
* A write-through console for one sandbox, tagging every line with the mount
|
||||
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
|
||||
* a mounted listener fires long after the mount call returned, and its output
|
||||
* must land somewhere the user can see — for a terminal entry point, the host terminal.
|
||||
*/
|
||||
function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
|
||||
const tag = `[cordis:${id}]`
|
||||
const log = (...args: unknown[]): void => { console.log(tag, ...args) }
|
||||
const error = (...args: unknown[]): void => { console.error(tag, ...args) }
|
||||
return { log, info: log, warn: log, debug: log, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch only VM constructors so `instanceof` accepts both VM values and host values passed as
|
||||
* arguments, events, or service results; host intrinsics remain untouched.
|
||||
*/
|
||||
const DUAL_REALM_INSTANCEOF_PRELUDE = `
|
||||
(hostIntrinsics) => {
|
||||
'use strict'
|
||||
const ordinary = Function.prototype[Symbol.hasInstance]
|
||||
for (const name of Object.keys(hostIntrinsics)) {
|
||||
const VmCtor = globalThis[name]
|
||||
const HostCtor = hostIntrinsics[name]
|
||||
if (typeof VmCtor !== 'function' || typeof HostCtor !== 'function') continue
|
||||
Object.defineProperty(VmCtor, Symbol.hasInstance, {
|
||||
value: (instance) => ordinary.call(VmCtor, instance) || ordinary.call(HostCtor, instance),
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/** Run {@link DUAL_REALM_INSTANCEOF_PRELUDE} in a freshly created sandbox, handing it the host intrinsics to pair up. */
|
||||
function patchDualRealmInstanceof(sandbox: object): void {
|
||||
const patch = runInContext(DUAL_REALM_INSTANCEOF_PRELUDE, sandbox) as (intrinsics: Record<string, unknown>) => void
|
||||
patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set })
|
||||
}
|
||||
|
||||
const TIMER_REDIRECT
|
||||
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
|
||||
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when unmounted.'
|
||||
|
||||
/**
|
||||
* The callable Node APIs the sandbox deliberately disables, each mapped to the
|
||||
* cordis alternative its trap error names. Only function-valued globals are
|
||||
* trapped; a data-valued global such as `process` stays `undefined`, because a
|
||||
* throwing accessor would detonate the common `typeof process` feature probe
|
||||
* at resolution time.
|
||||
*/
|
||||
const NODE_API_REDIRECTS: Record<string, string> = {
|
||||
require:
|
||||
'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
|
||||
+ '[\'web\'] for HTTP, [\'bash\'] for processes; cordis_inspect what:"api" lists what THIS runtime provides.',
|
||||
setTimeout: TIMER_REDIRECT,
|
||||
setInterval: TIMER_REDIRECT,
|
||||
setImmediate: TIMER_REDIRECT,
|
||||
clearTimeout: TIMER_REDIRECT,
|
||||
clearInterval: TIMER_REDIRECT,
|
||||
fetch:
|
||||
'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
|
||||
+ '(see cordis_inspect what:"api" for its methods).',
|
||||
}
|
||||
|
||||
/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
|
||||
function nodeApiTraps(): Record<string, () => never> {
|
||||
const traps: Record<string, () => never> = {}
|
||||
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
|
||||
traps[name] = () => {
|
||||
throw new Error(`${name} is not available in the temporary Plugin sandbox — ${redirect}`)
|
||||
}
|
||||
}
|
||||
return traps
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the vm context one `cordis_mount` call evaluates in: the tagged
|
||||
* console, the `harness` registration helpers, the encoding primitives, the
|
||||
* Node-API traps, and the dual-realm `instanceof` patch, already
|
||||
* `createContext`-ed.
|
||||
* @param id - the mount id (`dyn-<n>`), used as the console tag and filename stem.
|
||||
* @returns the contextified sandbox object to pass to {@link evaluateMountCode}.
|
||||
*/
|
||||
export function createSandbox(id: string): object {
|
||||
const sandbox = {
|
||||
...nodeApiTraps(),
|
||||
console: taggedConsole(id),
|
||||
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool },
|
||||
// Web APIs absent from fresh vm contexts — made available so the model
|
||||
// can encode/decode base64 without Buffer (which is also absent). Host
|
||||
// closures over Buffer, never Buffer itself.
|
||||
btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),
|
||||
atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
}
|
||||
createContext(sandbox)
|
||||
patchDualRealmInstanceof(sandbox)
|
||||
return sandbox
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-realm SyntaxError detection: a compile failure inside `runInContext`
|
||||
* constructs its error in the SANDBOX realm, so a host `instanceof
|
||||
* SyntaxError` is silently false — the `name` property is the realm-safe tag.
|
||||
*/
|
||||
function isSyntaxError(error: unknown): error is Error {
|
||||
return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'SyntaxError'
|
||||
}
|
||||
|
||||
/**
|
||||
* The parse-failure context a vm `SyntaxError` carries: the vm prints the
|
||||
* offending source line and a caret before the message, which is exactly what
|
||||
* a model needs to self-correct — surface it instead of the bare message.
|
||||
* Falls back to `String(error)` when the stack carries no such prelude.
|
||||
* @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling mount code.
|
||||
* @returns the stack prefix up to and including the `SyntaxError: …` line.
|
||||
*/
|
||||
export function syntaxErrorContext(error: Error): string {
|
||||
const lines = (error.stack ?? '').split('\n')
|
||||
const messageIndex = lines.findIndex(line => line.startsWith('SyntaxError'))
|
||||
if (messageIndex === -1) return String(error)
|
||||
return lines.slice(0, messageIndex + 1).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate mount code as the body of an async function inside the sandbox. `vmTimeoutMs` only
|
||||
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
|
||||
* trust stance. Parse errors include the offending line and a TypeScript-removal or bracket-
|
||||
* balance hint.
|
||||
* @param sandbox - the contextified object from {@link createSandbox}.
|
||||
* @param code - the model-written function body; must `return` a plugin.
|
||||
* @param id - the mount id, used as the vm filename (`cordis-mount-<id>.js`).
|
||||
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
|
||||
* @returns whatever the code returned, still un-narrowed (the mount lifecycle checks plugin shape).
|
||||
*/
|
||||
export async function evaluateMountCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown> {
|
||||
try {
|
||||
return await runInContext(
|
||||
`(async () => {\n${code}\n})()`,
|
||||
sandbox,
|
||||
{ filename: `cordis-mount-${id}.js`, timeout: vmTimeoutMs },
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isSyntaxError(error)) throw error
|
||||
const context = syntaxErrorContext(error)
|
||||
// Scope the TypeScript heuristic to the OFFENDING line, not the whole
|
||||
// code: an ` as ` inside an ordinary description string must not turn a
|
||||
// plain syntax error into a misleading remove-annotations message.
|
||||
const offendingLine = context.split('\n')[1] ?? ''
|
||||
if (/\bas\b/.test(offendingLine)) {
|
||||
throw new Error(
|
||||
`temporary Plugin code failed to parse:\n${context}\n`
|
||||
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
|
||||
+ ' ✗ { type: \'text\' as const, text: x }\n'
|
||||
+ ' ✓ { type: \'text\', text: x }',
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`temporary Plugin code failed to parse:\n${context}\n`
|
||||
+ 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
|
||||
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
|
||||
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.',
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user