Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md
#	.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md
#	docs/capability-seams.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	docs/rfc/INDEX.md
#	examples/acp-agent/README.md
#	examples/acp-agent/fs.cordis.snapshot.yml
#	examples/acp-agent/fs.cordis.yml
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json
#	examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json
#	packages/bash/bash/src/index.ts
#	packages/bash/tool-bash/package.json
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/fs/README.md
#	packages/fs/tool-fs/src/edit.ts
#	packages/fs/tool-fs/src/write.ts
#	packages/sandbox/README.md
#	pnpm-lock.yaml
This commit is contained in:
kingwl
2026-07-20 11:40:29 +08:00
1293 changed files with 74019 additions and 16297 deletions

View File

@@ -1,6 +1,6 @@
# packages/cordis — the self-referential runtime toolset
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
| Package | Role | ctx key |
|---|---|---|

View File

@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-tool-cordis
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset RFC](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## What it does
- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references.
- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-<n>`.
- `cordis_unmount` — disposes one mount by id, returning only after quiescence.
@@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata
## Trust stance
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## Config
@@ -22,7 +22,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
## The generated API catalog
`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time.
`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
## Rendering
@@ -36,21 +36,45 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau
### Tool schemas
**What the model sees**: The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
#### What the model sees
**Token effect**: Fixed schema cost on every request in that tool view.
The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
#### Token effect
Fixed schema cost on every request in that tool view.
#### KV Cache effect
Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle changes that hide these definitions may invalidate reuse from the first changed schema token.
### Tool-call history and results
**What the model sees**: Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections. Mount returns `mounted <id> (plugin "<name>", state: <state>)`, optionally inserting ` — waiting for service(s): <names> (activates when provided)` before the closing parenthesis. Unmount returns `unmounted <id> (plugin "<name>")`; an unknown id becomes `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history.
#### What the model sees
**Token effect**: Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small.
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted <id> (plugin "<name>", state: <state>)`, optionally inserting ` — waiting for service(s): <names> (activates when provided)` before the closing parenthesis. Unmount returns `unmounted <id> (plugin "<name>")`; an unknown id becomes `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history.
#### Token effect
Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Later requests after a mount
**What the model sees**: A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence.
#### What the model sees
**Token effect**: Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime.
A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence.
#### Token effect
Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime.
#### KV Cache effect
Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable.
## Known Limitations and Deferred Work

View File

@@ -33,6 +33,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,9 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { STATE_LABELS } from './fiber-state.ts'
import { isPlugin, pluginName } from './guard.ts'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts'
import { missingServices, mountDynamic } from './mount.ts'
import type { DynamicMount } from './mount.ts'
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
import { createSandbox, evaluateMountCode } from './sandbox.ts'
@@ -26,7 +26,7 @@ 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 docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
* bound — see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
*/
vmTimeoutMs?: number
}
@@ -63,15 +63,23 @@ export function apply(ctx: Context, config: Config): void {
+ '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), '
+ '`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). '
+ 'Omit `what` to get all six sections.',
+ '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', 'dynamic', '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".',
},
},
execute(args, exec): Promise<{ type: 'text'; text: 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: [heading: string, body: () => string[]][] = [
['services', () => describeServices(ctx)],
['plugins', () => describePlugins(ctx)],
@@ -79,8 +87,8 @@ export function apply(ctx: Context, config: Config): void {
// globals absent — "what you can call", not the global registry.
['tools', () => describeTools(ctx, exec.agent)],
['dynamic', () => describeDynamic(ctx, mounts)],
['api', () => describeApi(ctx)],
['events', () => describeEvents()],
['api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
['events', () => describeEvents(EVENT_API, args.name)],
]
const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading)
const text = selected

View File

@@ -1,7 +1,8 @@
/**
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
* plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits),
* and the catalog-backed `api` / `events` sections.
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the
* original source JSDoc without inflating the default reports.
* @module @deepseek-ai/dsh-tool-cordis/inspect
*/
@@ -126,6 +127,18 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
return [...included.values()].sort((a, b) => a.name.localeCompare(b.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}`)
}
lines.push(` ${method.signature}`)
}
return lines
}
/**
* Render the generated catalog against the live runtime: live catalogued services with methods,
* uncatalogued live services with owners, absent loadable services, referenced type shapes, and
@@ -134,6 +147,7 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn
* @param api - generated service entries, replaceable in tests.
* @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(
@@ -141,25 +155,33 @@ export function describeApi(
api: readonly ServiceApiEntry[] = SERVICE_API,
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 lines: string[] = []
const liveMethodTexts: string[] = []
for (const entry of api) {
if (!live.has(entry.key)) continue
lines.push(`- ${entry.key}${entry.summary}`)
let selected = api.filter(entry => live.has(entry.key))
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) {
lines.push(` ${method}`)
liveMethodTexts.push(method)
liveMethodTexts.push(method.signature)
}
}
const catalogued = new Set(api.map(entry => entry.key))
for (const [name, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) {
if (!catalogued.has(name)) lines.push(`- ${name} (provided by ${fiber}, no catalog entry)`)
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)`)
}
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
}
const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key)
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
const shapes = typeClosure(liveMethodTexts, types)
if (shapes.length > 0) {
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
@@ -167,8 +189,10 @@ export function describeApi(
for (const declLine of shape.declaration.split('\n')) lines.push(` ${declLine}`)
}
}
lines.push('inherited ctx API:')
for (const entry of inherited) lines.push(`- ${entry.name}${entry.summary}`)
if (name === undefined) {
lines.push('inherited ctx API:')
for (const entry of inherited) lines.push(`- ${entry.name}${entry.summary}`)
}
return lines
}
@@ -176,13 +200,24 @@ 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.
* @returns the section lines.
*/
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API): string[] {
const lines = events.flatMap(event => [
`- ${event.name} [${event.mode}] — ${event.summary}`,
` ${event.signature}`,
])
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] {
let selected = events
if (name !== undefined) {
const event = events.find(candidate => candidate.name === name)
if (!event) throw new Error(`no catalogued event named "${name}"`)
selected = [event]
}
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.signature}`)
return entry
})
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.')
return lines
}

View File

@@ -15,11 +15,12 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools'
* @param args - the validated call arguments.
* @returns the generic card the ACP bridge renders.
*/
export function presentInspectCall(args: { what?: string }): GenericCallView {
export function presentInspectCall(args: { what?: string; name?: string }): GenericCallView {
const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
return {
card: 'generic',
kind: 'read',
title: args.what === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${args.what}`,
title: target === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${target}`,
}
}

View File

@@ -64,6 +64,24 @@ describe('cordis_inspect', () => {
// The inherited ctx surface closes the section.
expect(report).toContain('inherited ctx API:')
expect(report).toContain('- ctx.effect — ')
// The broad report stays compact; exact-name lookup owns full JSDoc.
expect(report).not.toContain('/**')
expect(report).not.toContain('@param definition')
})
it('adds original method JSDoc only for an exact live api name', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_inspect', { what: 'api', name: 'tools' }))
expect(report).toContain('## api')
expect(report).toContain('- tools — Tool registry and execution pipeline.')
expect(report).toContain('/**')
expect(report).toContain('Register globally or in the calling agent scope.')
expect(report).toContain('@param definition - the tool schema')
expect(report).toContain('@returns the exact disposer')
expect(report).toContain('register(definition: ToolDefinition)')
expect(report).toContain('type shapes (referenced by the signatures above')
expect(report).not.toContain('not running (loadable services')
expect(report).not.toContain('inherited ctx API:')
})
it('renders the events section with mode badges, signatures, and the waterfall caution', async () => {
@@ -73,6 +91,39 @@ describe('cordis_inspect', () => {
expect(report).toContain('- tools/pre-execute [waterfall]')
expect(report).toMatch(/'agent\/status'\(/)
expect(report).toContain('returning without next() vetoes the chain')
expect(report).not.toContain('/**')
expect(report).not.toContain('@mode waterfall')
})
it('adds original event JSDoc only for an exact event name', async () => {
const ctx = await setup()
const report = text(await call(ctx, 'cordis_inspect', { what: 'events', name: 'tools/pre-execute' }))
expect(report).toContain('## events')
expect(report).toContain('- tools/pre-execute [waterfall]')
expect(report).toContain('/**')
expect(report).toContain('Allow, deny, or ask before dispatch.')
expect(report).toContain('@param exec - the pending call')
expect(report).toContain('@mode waterfall')
expect(report).not.toContain('- tools/change [emit]')
})
it('fails loud for incompatible, unknown, and non-running names', async () => {
const ctx = await setup()
const incompatible = await call(ctx, 'cordis_inspect', { what: 'tools', name: 'tools' })
expect(incompatible.isError).toBe(true)
expect(text(incompatible)).toContain('name is valid only with what:"api" or what:"events"')
const unknownService = await call(ctx, 'cordis_inspect', { what: 'api', name: 'not-a-service' })
expect(unknownService.isError).toBe(true)
expect(text(unknownService)).toContain('no catalogued service named "not-a-service"')
const nonRunning = await call(ctx, 'cordis_inspect', { what: 'api', name: 'bash' })
expect(nonRunning.isError).toBe(true)
expect(text(nonRunning)).toContain('catalogued service "bash" is not running')
const unknownEvent = await call(ctx, 'cordis_inspect', { what: 'events', name: 'not/an-event' })
expect(unknownEvent.isError).toBe(true)
expect(text(unknownEvent)).toContain('no catalogued event named "not/an-event"')
})
})
@@ -102,7 +153,11 @@ describe('inspect renderers (direct)', () => {
it('describeApi omits the not-running line and type shapes when nothing applies', async () => {
const ctx = await setup()
const lines = describeApi(ctx, [{ key: 'tools', summary: 'The registry.', methods: ['register(x): void'] }], [], [])
const lines = describeApi(ctx, [{
key: 'tools',
summary: 'The registry.',
methods: [{ signature: 'register(x): void', jsDoc: '/** Register x. */' }],
}], [], [])
expect(lines[0]).toBe('- tools — The registry.')
expect(lines[1]).toBe(' register(x): void')
expect(lines.join('\n')).not.toContain('not running')

View File

@@ -1,11 +1,9 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { REVERSE_TOOL_CODE } from './helpers.ts'
@@ -20,18 +18,14 @@ import { REVERSE_TOOL_CODE } from './helpers.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(ToolCordis)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -51,7 +45,7 @@ describe('cordis tools through the agent loop', () => {
textResponse('Done.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-cordis'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
await waitForIdle(ctx, agent)

View File

@@ -175,9 +175,13 @@ describe('cordis_mount', () => {
// The registered schema is canonical JSON Schema derived from the DSL:
// the required array survived, integer became number, extra is optional.
const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
const parameters = schema.parameters as { properties: Record<string, { type: string; enum?: string[] }>; required?: string[] }
const parameters = schema.parameters as {
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
required?: string[]
}
expect(parameters.required).toEqual(['text'])
expect(parameters.properties.count!.type).toBe('number')
expect(parameters.properties.count!.default).toBe(1)
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
// Arg validation enforces the normalized spec: text required, extra not.
expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true)

View File

@@ -11,6 +11,11 @@ describe('presenters', () => {
it('cordis_inspect renders a generic read card titled with the section', () => {
expect(presentInspectCall({})).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime' })
expect(presentInspectCall({ what: 'api' })).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime: api' })
expect(presentInspectCall({ what: 'events', name: 'tools/change' })).toEqual({
card: 'generic',
kind: 'read',
title: 'Inspect cordis runtime: events: tools/change',
})
})
it('cordis_mount renders a generic execute card carrying the code as raw input', () => {
@@ -33,6 +38,9 @@ describe('presenters', () => {
kind: 'read',
title: 'Inspect cordis runtime: tools',
})
expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({
title: 'Inspect cordis runtime: api: tools',
})
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' })
// Soft validation: presenter args that fail the schema render as no card, never a throw.

View File

@@ -32,8 +32,9 @@ describe('tool registration', () => {
const names = ctx.tools.schemas().map(schema => schema.name)
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
const props = (inspect.parameters as { properties: Record<string, { enum?: string[] }> }).properties
const props = (inspect.parameters as { properties: Record<string, { enum?: string[]; type?: string }> }).properties
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
expect(props.name?.type).toBe('string')
})
})