refactor(core): fold initiator scope into agents

This commit is contained in:
Tianyi Cui
2026-07-19 13:30:45 +08:00
parent 536cb6f985
commit c23214be56
79 changed files with 1883 additions and 1003 deletions

View File

@@ -2,7 +2,7 @@
* Shared AST walkers for the cordis documentation generators
* (`gen-cordis-catalog.ts`, `gen-website-api.ts`): locating the cordis module
* merge in a source file, enumerating its `interface Events` members, and
* resolving the `interface Context` service keys to their service declarations.
* resolving the `interface Context` service keys to their service classes.
* One walk, two renderers — the catalog and the website page carry different
* prose but must agree on WHAT exists.
*/
@@ -50,50 +50,45 @@ function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, str
return keyToType
}
/** One `ctx.<key>` service declaration resolved from a Context merge. */
export interface ServiceDeclaration {
/** One `ctx.<key>` service class resolved from a Context merge. */
export interface ServiceClass {
key: string
type: string
declaration: ts.ClassDeclaration | ts.InterfaceDeclaration
cls: ts.ClassDeclaration
abstract: boolean
/** Declaration-level JSDoc prose (empty string when missing — also reported). */
/** Class-level JSDoc prose (empty string when missing — also reported). */
doc: string
}
/**
* Resolve each `ctx.<key>` of a merge to the service class or interface declared in the
* Resolve each `ctx.<key>` of a merge to the service class declared in the
* same file. A key whose type is not a class here (a Pick-mixin member, e.g.
* timer helpers) is skipped. A declaration without JSDoc prose is reported into
* timer helpers) is skipped. A class without JSDoc prose is reported into
* `violations` (named `where` by the caller's gate).
*
* @param body — the cordis module merge body.
* @param sf — the source file containing the merge.
* @param rel — repo-relative path of `sf`, for violation pointers.
* @param violations — sink for JSDoc-completeness violations.
* @returns the resolved service declarations, in Context-declaration order.
* @returns the resolved service classes, in Context-declaration order.
*/
export function serviceDeclarations(
export function serviceClasses(
body: ts.ModuleBlock,
sf: ts.SourceFile,
rel: string,
violations: string[],
): ServiceDeclaration[] {
): ServiceClass[] {
const text = sf.getFullText()
const out: ServiceDeclaration[] = []
const out: ServiceClass[] = []
for (const [key, type] of contextKeyMap(body, sf)) {
const declaration = sf.statements.find(
(s): s is ts.ClassDeclaration | ts.InterfaceDeclaration =>
(ts.isClassDeclaration(s) || ts.isInterfaceDeclaration(s)) && s.name?.text === type,
const cls = sf.statements.find(
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
)
if (!declaration) continue // a Pick-mixin member, not a service declaration here
const abstract = ts.isInterfaceDeclaration(declaration)
|| (declaration.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false)
const doc = parseJsDoc(rawJsDoc(text, declaration)).doc
if (!doc) {
const kind = ts.isInterfaceDeclaration(declaration) ? 'interface' : 'class'
violations.push(`service ctx.${key} (${pointer(rel, sf, declaration)}): ${kind} ${type} has no JSDoc.`)
}
out.push({ key, type, declaration, abstract, doc })
if (!cls) continue // a Pick-mixin member, not a class here
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
out.push({ key, type, cls, abstract, doc })
}
return out
}

View File

@@ -80,7 +80,7 @@ function referencedTypes(seeds: string[], decls: Map<string, string>): { name: s
function render(): string {
const services = collectServices()
const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name))
const types = referencedTypes(services.flatMap(service => service.methods), collectTypeDecls())
const types = referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), collectTypeDecls())
const lines: string[] = [
'/**',
' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run',
@@ -145,7 +145,7 @@ function render(): string {
lines.push(' methods: [],')
} else {
lines.push(' methods: [')
for (const method of service.methods) lines.push(` ${quote(method)},`)
for (const method of service.methods) lines.push(` ${quote(method.signature)},`)
lines.push(' ],')
}
lines.push(' },')

View File

@@ -9,7 +9,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
import { cordisModuleBody, eventMembers, serviceDeclarations } from './cordis-walk.ts'
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
const root = resolve(import.meta.dirname, '..')
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
@@ -27,8 +27,6 @@ const FENCE = 'ts cordis-catalog'
// TODO(catalog-type-links): verify or generate link-map coverage.
export const LINK_MAP: Record<string, string> = {
Agent: 'core.md',
AgentExecution: 'core.md',
AgentExecutionService: 'core.md',
ContentBlock: 'core.md',
Message: 'core.md',
MessageSource: 'core.md',
@@ -74,6 +72,8 @@ interface EventEntry {
scope: string
/** Full signature text (the method-signature member, JSDoc stripped). */
signature: string
/** Original declaration JSDoc, dedented from its containing interface. */
jsDoc: string
/** Dispatch mode from the `@mode` tag. */
mode: Mode
/** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
@@ -82,19 +82,27 @@ interface EventEntry {
source: string
}
/** One public service method and the source contract attached to it. */
interface ServiceMethodEntry {
/** Public method signature (body stripped). */
signature: string
/** Original method JSDoc, dedented from its containing class. */
jsDoc: string
}
/** One harness service, extracted from an `interface Context` block. */
interface ServiceEntry {
/** The `ctx.<key>` name, e.g. `llm`. */
key: string
/** The service class/interface name, e.g. `LlmService`. */
type: string
/** Whether the service declaration is abstract (a seam interface). */
/** Whether the service class is abstract (a seam interface). */
abstract: boolean
/** Declaration-level JSDoc prose, one line per paragraph. */
/** Class-level JSDoc prose, one line per paragraph. */
doc: string
/** Public method signatures (bodies stripped), in source order. */
methods: string[]
/** Source pointer of the service declaration. */
/** Public methods (bodies stripped), in source order. */
methods: ServiceMethodEntry[]
/** Source pointer of the class declaration. */
source: string
}
@@ -117,6 +125,22 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
}
/**
* Copy a node's original JSDoc while removing only the indentation imposed by
* its containing interface or class.
*/
function jsDocText(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
if (!raw) return ''
const start = text.lastIndexOf(raw, node.getStart(sf))
const { line } = sf.getLineAndCharacterOfPosition(start)
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, start)
return raw.split('\n')
.map((lineText, index) => index > 0 && lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
.join('\n')
}
/** Walk every harness `interface Events` block and extract its events, hard-
* erroring (aggregated) on any JSDoc-completeness violation: a missing/
* contradicted `@mode`, missing description prose, or an undocumented payload
@@ -157,15 +181,15 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
const { params } = parseTags(raw)
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, jsDoc: jsDocText(text, sf, member), mode, doc, source: src })
}
}
reportViolations('gen-cordis-catalog', violations)
return entries
}
/** Walk every harness `interface Context` block + its service declaration, hard-
* erroring (aggregated) on any JSDoc-completeness violation: a declaration or public
/** Walk every harness `interface Context` block + its service class, hard-
* erroring (aggregated) on any JSDoc-completeness violation: a class or public
* method without JSDoc prose, an undocumented parameter, a stale `@param`, a
* missing `@returns` on a non-void method, or an inferred (unannotated) return
* type the pure-AST walk cannot classify.
@@ -180,11 +204,11 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
// Resolve each ctx key to its service declaration (shared walk) and emit an entry.
for (const { key, type, declaration, abstract, doc: declarationDoc } of serviceDeclarations(body, sf, rel, violations)) {
const methods: string[] = []
for (const member of declaration.members) {
if (!ts.isMethodDeclaration(member) && !ts.isMethodSignature(member)) continue
// Resolve each ctx key to its service class (shared walk) and emit an entry.
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
const methods: ServiceMethodEntry[] = []
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member)) continue
// Only instance methods callable through `ctx.<key>` are surface;
// private, protected, and static methods are not.
const nonPublic = member.modifiers?.some(m =>
@@ -195,9 +219,9 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
if (nonPublic) continue
const memberName = member.name.getText(sf)
if (memberName.startsWith('[')) continue // computed/symbol members
methods.push(memberSignature(member, sf))
const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
const raw = rawJsDoc(text, member)
methods.push({ signature: memberSignature(member, sf), jsDoc: jsDocText(text, sf, member) })
if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
const { params, returns } = parseTags(raw)
@@ -212,9 +236,9 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
key,
type,
abstract,
doc: declarationDoc,
doc: clsDoc,
methods,
source: pointer(rel, sf, declaration),
source: pointer(rel, sf, cls),
})
}
}
@@ -277,7 +301,7 @@ function typeLinks(signature: string): string {
function renderEvent(e: EventEntry): string[] {
const out = [`### \`${e.name}\`${e.mode}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, e.signature, '```', '')
out.push('```' + FENCE, e.jsDoc, e.signature, '```', '')
const links = typeLinks(e.signature)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
@@ -290,8 +314,13 @@ function renderService(s: ServiceEntry): string[] {
const out = [`## \`ctx.${s.key}\`\`${s.type}\`${kind}`, '']
if (s.doc) out.push(s.doc, '')
if (s.methods.length) {
out.push('```' + FENCE, ...s.methods, '```', '')
const links = typeLinks(s.methods.join('\n'))
const declarations = s.methods.flatMap((method, index) => [
...(index > 0 ? [''] : []),
method.jsDoc,
method.signature,
])
out.push('```' + FENCE, ...declarations, '```', '')
const links = typeLinks(s.methods.map(method => method.signature).join('\n'))
if (links) out.push(links, '')
}
out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
@@ -306,15 +335,15 @@ const BANNER = [
]
/** The shared GENERATED + freshness-gate + fence notice paragraph. */
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.'
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.'
/** Render the events catalog (pure, deterministic given sorted inputs). */
function renderEvents(events: EventEntry[]): string {
export function renderEvents(events: EventEntry[]): string {
const lines: string[] = [
...BANNER,
'# Cordis Events Catalog',
'',
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration\'s JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
'',
GATE_NOTICE,
'',
@@ -344,12 +373,12 @@ function renderEvents(events: EventEntry[]): string {
}
/** Render the services catalog (pure, deterministic given sorted inputs). */
function renderServices(services: ServiceEntry[]): string {
export function renderServices(services: ServiceEntry[]): string {
const lines: string[] = [
...BANNER,
'# Cordis Services Catalog',
'',
'Every `ctx.<key>` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
'Every `ctx.<key>` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
'',
GATE_NOTICE,
'',

View File

@@ -156,18 +156,10 @@ const SERVICE_ROLES: ServiceRole[] = [
{
key: 'agents',
pkg: 'agent',
title: 'Agent registry',
title: 'Agent service',
mode: 'core',
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'],
note: 'Owns live Agent handles and the create/resume factory seam.',
},
{
key: 'agentExecution',
pkg: 'agent-execution',
title: 'Agent execution context',
mode: 'core',
consumers: ['agent-loop'],
note: 'Carries the exact initiating Agent across one process-local asynchronous driver chain; explicit identities remain authoritative at external boundaries.',
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
key: 'agentLoop',

View File

@@ -35,7 +35,7 @@ import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
import { cordisModuleBody, eventMembers, serviceDeclarations } from './cordis-walk.ts'
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
const root = resolve(import.meta.dirname, '..')
@@ -197,18 +197,6 @@ function isPublicInstance(member: ts.ClassElement): boolean {
return !member.name.getText().startsWith('_')
}
type HarnessServiceMember = ts.MethodDeclaration | ts.MethodSignature | ts.PropertyDeclaration
| ts.PropertySignature | ts.GetAccessorDeclaration
/** Whether a class/interface service member is renderable public API. */
function isPublicServiceMember(member: HarnessServiceMember): boolean {
if (ts.isMethodSignature(member) || ts.isPropertySignature(member)) {
if (ts.isComputedPropertyName(member.name)) return false
return !member.name.getText().startsWith('_')
}
return isPublicInstance(member)
}
/** Whether a class member is renderable public STATIC API. */
function isPublicStatic(member: ts.ClassElement): boolean {
const mods = ts.getCombinedModifierFlags(member)
@@ -465,15 +453,14 @@ function collectHarnessServices(violations: string[]): HarnessService[] {
// Manifest shape is repo-owned; `name` is the one field read here.
const manifest = JSON.parse(readFileSync(pkgJson, 'utf8')) as { name: string }
const pkg = manifest.name
for (const { key, type, declaration, abstract, doc: declarationDoc } of serviceDeclarations(body, sf, rel, violations)) {
const groups = new Map<string, HarnessServiceMember[]>()
for (const member of declaration.members) {
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
const groups = new Map<string, (ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration)[]>()
for (const member of cls.members) {
// Public properties are API too: ctx.codeRuntime.language/isolation
// are readonly descriptors consumers key presentation off.
const renderable = ts.isMethodDeclaration(member) || ts.isMethodSignature(member)
|| ts.isPropertyDeclaration(member) || ts.isPropertySignature(member) || ts.isGetAccessorDeclaration(member)
const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member)
if (!renderable) continue
if (!isPublicServiceMember(member)) continue
if (!isPublicInstance(member)) continue
const name = member.name.getText(sf)
const group = groups.get(name) ?? []
group.push(member)
@@ -481,7 +468,7 @@ function collectHarnessServices(violations: string[]): HarnessService[] {
}
const members = [...groups.entries()].map(([name, group]) =>
memberDoc(`ctx.${key}.${name} (${rel})`, name, group, rel, violations))
services.push({ key, type, abstract, doc: declarationDoc, members, source: pointer(rel, sf, declaration), pkg })
services.push({ key, type, abstract, doc: clsDoc, members, source: pointer(rel, sf, cls), pkg })
}
}
return services.sort((a, b) => a.key.localeCompare(b.key))

View File

@@ -15,8 +15,6 @@
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecution", "source": "packages/core/agent-execution/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecutionService", "source": "packages/core/agent-execution/src/index.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },

View File

@@ -28,7 +28,6 @@ interface SentenceContract {
* so an absent section cannot be mistaken for forgotten documentation.
*/
const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
'packages/core/agent-execution': 'The package adds no model-visible text or schema; consumers own any use in model requests.',
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',