2026-07-08 11:45:46 +08:00
/**
2026-07-27 21:20:06 +08:00
* 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,
2026-07-13 23:27:00 +08:00
* 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.bash` reaches the real
* runtime. Named exports preserve loader injection metadata.
2026-07-08 11:45:46 +08:00
* @module @deepseek-ai/dsh-tool-cordis
*/
2026-07-08 21:01:49 +08:00
import type { Context } from 'cordis'
2026-07-08 11:45:46 +08:00
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { STATE_LABELS } from './fiber-state.ts'
import { isPlugin , pluginName } from './guard.ts'
2026-07-19 14:39:49 +08:00
import { EVENT_API , INHERITED_CTX_API , SERVICE_API , TYPE_API } from './api-catalog.ts'
2026-07-21 03:08:35 +08:00
import { describeApi , describeDynamic , describeEvents , describePlugins , describeServices , describeTools , providedServices } from './inspect.ts'
2026-07-19 14:39:49 +08:00
import { missingServices , mountDynamic , type DynamicMount } from './mount.ts'
2026-07-27 21:20:06 +08:00
import { presentInspectCall , presentMountCall , presentUnmountCall } from './present.ts'
2026-07-08 11:45:46 +08:00
import { createSandbox , evaluateMountCode } from './sandbox.ts'
export const name = 'tool-cordis'
export const inject = [ 'tools' ]
/** 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
2026-07-19 22:50:49 +08:00
* bound — see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
2026-07-08 11:45:46 +08:00
*/
vmTimeoutMs? : number
}
/** 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 >
/**
2026-07-27 16:57:26 +08:00
* Register the three cordis tools and own every temporary plugin under one
* `cordis-dynamic` group fiber.
2026-07-08 11:45:46 +08:00
* @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
2026-07-12 03:36:43 +08:00
// The one group fiber every dynamic mount hangs under.
2026-07-08 11:45:46 +08:00
const group = ctx . plugin ( { name : 'cordis-dynamic' , apply : ( ) = > { } } )
const mounts = new Map < string , DynamicMount > ( )
let nextId = 1
ctx . tools . register ( defineTool ( {
name : 'cordis_inspect' ,
description :
2026-07-27 16:57:26 +08:00
'Inspect the live Cordis runtime in the current DSH process. Read-only. '
2026-07-08 11:45:46 +08:00
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
2026-07-27 16:57:26 +08:00
+ '`plugins` (all live plugin fibers with their lifecycle states), '
2026-07-08 11:45:46 +08:00
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
2026-07-27 21:20:06 +08:00
+ '`temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), '
2026-07-08 11:45:46 +08:00
+ '`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). '
2026-07-27 21:20:06 +08:00
+ '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. '
2026-07-27 16:57:26 +08:00
+ '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` '
2026-07-19 14:39:49 +08:00
+ 'to narrow to one service/event and include its original source JSDoc.' ,
2026-07-08 11:45:46 +08:00
parameters : {
what : {
type : 'string' ,
2026-07-27 16:57:26 +08:00
enum : [ 'services' , 'plugins' , 'tools' , 'temporary' , 'api' , 'events' ] ,
2026-07-08 11:45:46 +08:00
description : 'Limit the report to one section. Omit for all sections.' ,
} ,
2026-07-19 14:39:49 +08:00
name : {
type : 'string' ,
description : 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".' ,
} ,
2026-07-08 11:45:46 +08:00
} ,
2026-07-21 03:08:35 +08:00
output : {
schema : { type : 'string' } ,
render : ( _args , value ) = > [ { type : 'text' , text : value } ] ,
} ,
execute ( args , exec ) : Promise < string > {
2026-07-19 14:39:49 +08:00
if ( args . name !== undefined && args . what !== 'api' && args . what !== 'events' ) {
throw new Error ( 'name is valid only with what:"api" or what:"events"' )
}
2026-07-27 16:57:26 +08:00
const sections : [ key : string , heading : string , body : ( ) = > string [ ] ] [ ] = [
[ 'services' , 'services' , ( ) = > describeServices ( ctx ) ] ,
[ 'plugins' , 'plugins' , ( ) = > describePlugins ( ctx ) ] ,
2026-07-09 23:24:42 +08:00
// The calling agent's view: scoped/shadowed tools included, restricted
// globals absent — "what you can call", not the global registry.
2026-07-27 16:57:26 +08:00
[ '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 ) ] ,
2026-07-08 11:45:46 +08:00
]
2026-07-27 16:57:26 +08:00
const selected = sections . filter ( ( [ key ] ) = > args . what === undefined || args . what === key )
2026-07-08 11:45:46 +08:00
const text = selected
2026-07-27 16:57:26 +08:00
. map ( ( [ , heading , body ] ) = > ` ## ${ heading } \ n ${ body ( ) . join ( '\n' ) } ` )
2026-07-08 11:45:46 +08:00
. join ( '\n\n' )
2026-07-21 03:08:35 +08:00
return Promise . resolve ( text )
2026-07-08 11:45:46 +08:00
} ,
presentCall : presentInspectCall ,
} ) )
ctx . tools . register ( defineTool ( {
2026-07-27 21:20:06 +08:00
name : 'cordis_mount' ,
2026-07-08 11:45:46 +08:00
description :
2026-07-27 21:20:06 +08:00
'Mount a temporary Cordis Plugin in the current DSH process. '
2026-07-27 16:57:26 +08:00
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. '
2026-07-27 21:20:06 +08:00
+ 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. '
2026-07-27 16:57:26 +08:00
+ '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 a normal local, project, or repository Plugin 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 '
2026-07-08 11:45:46 +08:00
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
2026-07-09 12:44:57 +08:00
+ '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.bash) throws; use it only when you need no services. '
2026-07-08 11:45:46 +08:00
+ 'OBJECT form `return { name?, inject: [\'bash\', \'llm\', …], apply(ctx) { … } }` '
+ '— declares dependencies, and cordis activates the plugin only after the '
2026-07-09 12:44:57 +08:00
+ '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 '
2026-07-27 21:20:06 +08:00
+ 'dependency would not be cleaned up if its provider is unmounted. '
2026-07-08 11:45:46 +08:00
+ '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: '
2026-07-21 03:08:35 +08:00
+ '{ text: { type: \'string\', required: true } }, output: { schema: { type: \'string\' }, '
+ 'render(_args, value) { return [{ type: \'text\', text: value }] } }, async execute(args) { return args.text } }))` '
2026-07-08 14:51:35 +08:00
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
2026-07-21 01:11:55 +08:00
+ '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 '
2026-07-21 03:08:35 +08:00
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
+ '`output.render(args, value)` separately returns Native/model content blocks. '
2026-07-27 16:57:26 +08:00
+ 'Temporary Plugins can COMPOSE: one Plugin may `ctx.provide(\'name\', value)` a service and '
2026-07-08 11:45:46 +08:00
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
2026-07-27 21:20:06 +08:00
+ 'until the provider exists and returns to pending when the provider is unmounted. '
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_unmount. '
2026-07-08 11:45:46 +08:00
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
2026-07-08 11:51:24 +08:00
+ '`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.bash for processes, '
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
2026-07-27 21:20:06 +08:00
+ 'auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. '
2026-07-08 11:45:46 +08:00
+ '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()` '
+ 'VETOES 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). '
2026-07-09 01:37:40 +08:00
+ '(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.bash) reach the '
+ 'real runtime.' ,
2026-07-08 11:45:46 +08:00
parameters : {
code : {
type : 'string' ,
required : true ,
2026-07-27 16:57:26 +08:00
description : 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.' ,
2026-07-08 11:45:46 +08:00
} ,
} ,
2026-07-21 03:08:35 +08:00
output : {
schema : {
type : 'object' ,
additionalProperties : false ,
properties : {
id : { type : 'string' , required : true } ,
pluginName : { type : 'string' , required : true } ,
state : {
type : 'string' ,
required : true ,
enum : [ 'pending' , 'loading' , 'active' , 'failed' , 'disposed' , 'unloading' ] ,
} ,
provides : { type : 'array' , required : true , items : { type : 'string' } } ,
waitingFor : { type : 'array' , required : true , items : { type : 'string' } } ,
} ,
} ,
render : ( _args , value ) = > {
2026-07-27 16:57:26 +08:00
const status = value . waitingFor . length > 0
? ` is pending (plugin " ${ value . pluginName } "; missing services: ${ value . waitingFor . join ( ', ' ) } `
: ` is running (plugin " ${ value . pluginName } " `
2026-07-21 03:08:35 +08:00
return [ {
type : 'text' ,
2026-07-27 21:20:06 +08:00
text : ` Temporary Plugin ${ value . id } ${ status } ; available until unmounted or DSH restarts). ` ,
2026-07-21 03:08:35 +08:00
} ]
} ,
} ,
2026-07-08 11:45:46 +08:00
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 (
2026-07-27 16:57:26 +08:00
'temporary Plugin code returned `undefined` — did you forget `return`?\n'
2026-07-08 11:45:46 +08:00
+ ' ✓ return (ctx) => { … }\n'
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }' ,
)
}
throw new Error (
2026-07-27 16:57:26 +08:00
'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method' ,
2026-07-08 11:45:46 +08:00
)
}
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 ]
2026-07-21 03:08:35 +08:00
return {
id ,
pluginName : pluginName ( evaluated ) ,
state ,
provides : providedServices ( ctx , fiber ) ,
waitingFor : missing ,
}
2026-07-08 11:45:46 +08:00
} ,
2026-07-27 21:20:06 +08:00
presentCall : presentMountCall ,
2026-07-08 11:45:46 +08:00
} ) )
ctx . tools . register ( defineTool ( {
2026-07-27 21:20:06 +08:00
name : 'cordis_unmount' ,
2026-07-08 11:45:46 +08:00
description :
2026-07-27 21:20:06 +08:00
'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. '
2026-07-27 16:57:26 +08:00
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.' ,
2026-07-08 11:45:46 +08:00
parameters : {
id : {
type : 'string' ,
required : true ,
2026-07-27 21:20:06 +08:00
description : 'The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart.' ,
2026-07-08 11:45:46 +08:00
} ,
} ,
2026-07-21 03:08:35 +08:00
output : {
schema : {
type : 'object' ,
additionalProperties : false ,
properties : {
id : { type : 'string' , required : true } ,
pluginName : { type : 'string' , required : true } ,
} ,
} ,
2026-07-27 21:20:06 +08:00
render : ( _args , value ) = > [ { type : 'text' , text : ` Temporary Plugin ${ value . id } was unmounted and removed. ` } ] ,
2026-07-21 03:08:35 +08:00
} ,
2026-07-08 11:45:46 +08:00
async execute ( args ) {
const mount = mounts . get ( args . id )
if ( ! mount ) {
2026-07-27 16:57:26 +08:00
throw new Error ( ` no temporary Plugin with id " ${ args . id } " (list them with cordis_inspect what:"temporary") ` )
2026-07-08 11:45:46 +08:00
}
await mount . fiber . dispose ( )
mounts . delete ( args . id )
2026-07-21 03:08:35 +08:00
return { id : args.id , pluginName : mount.pluginName }
2026-07-08 11:45:46 +08:00
} ,
2026-07-27 21:20:06 +08:00
presentCall : presentUnmountCall ,
2026-07-08 11:45:46 +08:00
} ) )
}