Merge origin/master into worktree/agent-execution-context-rfc

This commit is contained in:
Yichen Jiang
2026-07-18 21:20:30 +08:00
105 changed files with 3306 additions and 554 deletions

View File

@@ -4,8 +4,8 @@
"docs/architecture.md": 1790,
"docs/cordis-primer.md": 600,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 800,
"examples/AGENTS.md": 200,
"docs/testing.md": 960,
"examples/AGENTS.md": 310,
"packages/AGENTS.md": 290,
"packages/README.md": 760
}

View File

@@ -40,6 +40,7 @@ export const LINK_MAP: Record<string, string> = {
TurnEndReason: 'session.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolExecutionMode: 'tools.md',
ToolExecutionInput: 'tools.md',
ToolExecutionResult: 'tools.md',
ToolExecutionToken: 'tools.md',

View File

@@ -855,10 +855,19 @@ function renderLifecycle(): string {
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
` Driver->>Session: ${mermaidCode('assistant/message')}`,
` Driver->>Session: ${mermaidCode('tool/call')}`,
' Driver->>Tools: execute through pre and post waterfalls',
' Tools-->>Session: tool-owned events when applicable',
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
' Driver->>Tools: classify pending call by executionMode',
' loop barriers and bounded rolling pool, reclassify before start',
' opt call starts',
` Driver->>Session: ${mermaidCode('tool/call')}`,
' Driver->>Tools: ordered pre, concurrent execute',
' Tools-->>Session: tool-owned events when applicable',
' end',
' opt next model-order result ready',
' Driver->>Tools: ordered post',
` Driver->>Session: ${mermaidCode('tool/result')}`,
' end',
' end',
` Driver->>Session: ${mermaidCode('step/end')}`,
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
` Driver->>Session: ${mermaidCode('turn/end')}`,
@@ -895,9 +904,9 @@ function renderToolPipeline(): string {
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
' context["Buffered additionalContexts<br/>context/message after all tool results"]',
' context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
' allResults["Tool batch settled<br/>recorded tool/result events complete"]',
' presentResult["UI completed card<br/>presentResult(args, result)"]',
' model --> toolCall',
' toolCall --> presentCall',

View File

@@ -1,7 +1,7 @@
/**
* Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and
* the owning `SurfaceEventType` union. This is the durable-record vocabulary,
* not the live Cordis bus. Event declarations must be unique, explicitly typed,
* the owning event-envelope types. This is the durable-record vocabulary, not
* the live Cordis bus. Event declarations must be unique, explicitly typed,
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
* surface-union member must resolve to one. `--check` verifies the artifact.
*/
@@ -14,13 +14,23 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/persistence-catalog.md'
/** The fenced-block info string for generated payload blocks (skipped by
* doc-typecheck, since a bare payload fragment is not standalone-compilable). */
/** The fenced-block info string for generated declaration blocks (skipped by
* doc-typecheck, since their imported types are not standalone-compilable). */
const FENCE = 'ts persistence-catalog'
/** The package whose module id plugin merges augment (`declare module '…'`). */
const SESSION_MODULE = '@deepseek-ai/dsh-session'
/** Event-envelope declarations rendered before the per-event vocabulary. */
const EVENT_ENVELOPE_TYPE_NAMES = [
'SessionEventType',
'SurfaceEventType',
'SurfaceOp',
'SessionEvent',
] as const
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
/** Primary core-data-structures page for linked payload types. */
const LINK_MAP: Record<string, string> = {
CallId: 'core.md',
@@ -41,6 +51,8 @@ export interface LogEventEntry {
scope: string
/** Payload type text (the member's type annotation, whitespace-collapsed). */
payload: string
/** Source member declaration and complete JSDoc, dedented from its container. */
declaration: string
/** Description prose (the member's JSDoc), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
@@ -53,6 +65,16 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
surface: boolean
}
/** One owning event-envelope declaration pasted into the generated catalog. */
export interface EventEnvelopeTypeEntry {
/** Exported declaration name. */
name: EventEnvelopeTypeName
/** Verbatim type declaration, including its complete leading JSDoc. */
declaration: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
const printer = ts.createPrinter({ removeComments: true })
/**
@@ -67,6 +89,24 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
.trim()
}
/**
* Copy a declaration from its leading JSDoc through its closing token while
* removing only the indentation imposed by its containing interface/module.
*/
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
const nodeStart = node.getStart(sf)
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
const { line } = sf.getLineAndCharacterOfPosition(start)
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, start)
return text.slice(lineStart, node.end)
.split('\n')
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
.join('\n')
.trimEnd()
}
/**
* Every `interface SessionEventMap` declaration in a source file: the owning
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
@@ -177,7 +217,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
if (!doc) {
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
}
entries.push({ name, scope: name.split('/')[0] ?? name, payload, doc, source: src })
const declaration = declarationText(text, sf, member)
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src })
}
}
}
@@ -185,6 +226,51 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
return entries
}
/**
* Collect the exported declarations that compose the persisted event envelope,
* preserving their source JSDoc and declaration text.
*/
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
const violations: string[] = []
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
const name = stmt.name.text as EventEnvelopeTypeName
const src = pointer(rel, sf, stmt)
const where = `event-envelope type '${name}' (${src})`
const prior = found.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
continue
}
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
violations.push(`${where} is not exported.`)
}
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
}
}
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
if (missing.length > 0) {
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
}
reportViolations('gen-persistence-catalog', violations)
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
const entry = found.get(name)
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
return entry
})
}
/**
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
* types — from source. Hard-errors when the alias is missing, declared more
@@ -246,8 +332,7 @@ function typeLinks(payload: string): string {
/** Render one log event entry. */
function renderEvent(e: AnnotatedLogEventEntry): string[] {
const out = [`#### \`${e.name}\`${e.surface ? 'surface' : 'log-only'}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '')
out.push('```' + FENCE, e.declaration, '```', '')
const links = typeLinks(e.payload)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
@@ -255,18 +340,26 @@ function renderEvent(e: AnnotatedLogEventEntry): string[] {
}
/** Render the full catalog (pure, deterministic given the collected inputs). */
export function render(events: AnnotatedLogEventEntry[]): string {
export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnvelopeTypeEntry[]): string {
const lines: string[] = [
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
'',
'# Persistence Log Event Catalog',
'# Session Persistence Event Catalog',
'',
'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
'',
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
'',
'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
'',
'## Event envelope',
'',
'```' + FENCE,
envelopeTypes.map(entry => entry.declaration).join('\n\n'),
'```',
'',
`Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
'',
'## Events',
'',
@@ -285,7 +378,7 @@ export function render(events: AnnotatedLogEventEntry[]): string {
* is stale. Guarded behind an entry-point check so importing this module for
* tests neither regenerates the committed file nor calls process.exit. */
function main(): void {
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()))
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes())
if (process.argv.includes('--check')) {
let committed: string | null = null
try {

View File

@@ -163,11 +163,13 @@ function gatesForMode(selected: Mode): Gate[] {
]
case 'ci-coverage':
return [
pnpmScript('build', 'build'),
coverageGate(),
]
case 'ci-snapshot':
return [
pnpmScript('snapshot', 'test:snapshot'),
pnpmScript('build', 'build'),
snapshotGate(),
]
case 'ci-artifacts':
return ciArtifactGates()
@@ -186,7 +188,7 @@ function gatesForMode(selected: Mode): Gate[] {
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('test', 'test'),
pnpmScript('duplication', 'duplication'),
pnpmScript('snapshot', 'test:snapshot'),
snapshotGate(),
pnpmScript('build', 'build'),
...hygieneLeafGates({ artifactNeeds: ['build'] }),
...docSyncLeafGates({
@@ -207,7 +209,7 @@ function ciPrimaryGates(): Gate[] {
lintGate(),
pnpmScript('duplication', 'duplication'),
coverageGate(),
pnpmScript('snapshot', 'test:snapshot'),
snapshotGate(),
demoSmokeGate({ needs: ['lint'] }),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
@@ -281,6 +283,18 @@ function coverageGate(): Gate {
...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
], {
label: 'test:coverage',
env: { DSH_EXAMPLE_MODE: 'lib' },
needs: ['build'],
})
}
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
// plugins via real exports) — CI and pre-push already build, so they exercise what ships rather
// than the tsx/source path dev uses. It therefore waits on `build`.
function snapshotGate(): Gate {
return pnpmScript('snapshot', 'test:snapshot', {
env: { DSH_EXAMPLE_MODE: 'lib' },
needs: ['build'],
})
}

View File

@@ -74,6 +74,7 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" },

View File

@@ -1,18 +1,32 @@
/**
* Reject JavaScript expressions in Cordis Loader entry metadata.
* Validate Cordis Loader entry metadata and example package resolution.
*
* The Loader interpolates only a plugin entry's `config`; expression objects in
* fields such as `disabled` remain truthy data and silently change composition.
* Example configs run from built packages, so every named package must resolve
* from the examples workspace and every local package must be in the root
* TypeScript project graph.
*/
import { globSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { dirname, relative, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import ts from 'typescript'
interface JsExpr {
__jsExpr: string
}
interface PackageManifest {
name?: string
dependencies?: Record<string, string>
}
interface PluginReference {
file: string
name: string
}
const root = resolve(import.meta.dirname, '..')
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
@@ -30,6 +44,7 @@ const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
}).sort()
const errors: string[] = []
const examplePluginReferences: PluginReference[] = []
for (const file of files) {
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
@@ -42,8 +57,10 @@ for (const file of files) {
}
}
errors.push(...validateExampleResolution())
if (errors.length > 0) {
console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
for (const error of errors) console.error(`- ${error}`)
process.exitCode = 1
} else {
@@ -55,6 +72,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
errors.push(`${file}${path}: entry must be an object`)
return
}
recordExamplePlugin(value, file)
validateMetadata(value, file, path)
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
for (let index = 0; index < value.config.length; index++) {
@@ -68,6 +86,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
const patch = config.patches[index]
const patchPath = `${path}.config.patches[${index}]`
if (!isRecord(patch)) continue
recordExamplePlugin(patch, file)
validateMetadata(patch, file, patchPath)
if (!isUnknownArray(patch.insert)) continue
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
@@ -76,6 +95,83 @@ function validateEntry(value: unknown, file: string, path: string): void {
}
}
function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
if (file.startsWith('examples/') && typeof entry.name === 'string') {
examplePluginReferences.push({ file, name: entry.name })
}
}
function validateExampleResolution(): string[] {
const violations: string[] = []
const exampleManifest = readManifest('examples/package.json')
const dependencies = exampleManifest.dependencies ?? {}
const localPackages = localPackageDirectories()
const rootReferences = rootProjectReferences()
const requiredPackages = new Map<string, Set<string>>()
for (const reference of examplePluginReferences) {
const packageName = packageNameFromSpecifier(reference.name)
if (packageName === undefined) continue
const locations = requiredPackages.get(packageName) ?? new Set<string>()
locations.add(reference.file)
requiredPackages.set(packageName, locations)
}
for (const [packageName, locations] of requiredPackages) {
if (!(packageName in dependencies)) {
violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
}
}
const localExamplePackages = new Set([
...Object.keys(dependencies),
...requiredPackages.keys(),
])
for (const packageName of localExamplePackages) {
const packageDirectory = localPackages.get(packageName)
if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
}
return violations
}
function readManifest(path: string): PackageManifest {
return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
}
function localPackageDirectories(): Map<string, string> {
const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
const packages = new Map<string, string>()
for (const manifestPath of manifests) {
const manifest = readManifest(manifestPath)
if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
}
return packages
}
function rootProjectReferences(): Set<string> {
const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), path => ts.sys.readFile(path))
if (config.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
}
const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
return new Set(references.flatMap((reference) => {
if (typeof reference.path !== 'string') return []
return [resolve(root, reference.path)]
}))
}
function packageNameFromSpecifier(specifier: string): string | undefined {
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
const segments = specifier.split('/')
if (specifier.startsWith('@')) {
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
}
return segments[0] || undefined
}
function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
for (const field of metadataFields) {
if (!(field in entry)) continue