Merge branch 'simpl-a1-drop-image' into simpl-a2-vocab
This commit is contained in:
@@ -79,7 +79,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// Release quiescence waiters on a transition OUT of running BEFORE emitting
|
||||
// (the disposer handles the disposed transition separately). Settling first
|
||||
// means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
|
||||
// waiter (AGENTS.md "contain callback exceptions" — a lifecycle await must
|
||||
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
|
||||
// not hang on one bad listener).
|
||||
if (status !== 'running') this.settleIdleWaiters()
|
||||
try {
|
||||
|
||||
@@ -121,6 +121,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* deliberate resume-or-create policy (resume the prior session if one exists,
|
||||
* else start fresh) or an explicit caller-chosen session id — revisit when the
|
||||
* UI/ACP path owns session selection.
|
||||
* @param id - the agent id; also seeds the generated session id.
|
||||
* @param options - loop options (model, limits, …); defaults applied per option.
|
||||
* @returns the running agent, owned by the calling fiber (no handle).
|
||||
*/
|
||||
create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
|
||||
this.assertAgentIdFree(id)
|
||||
@@ -142,6 +145,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
|
||||
* starts with the parent's context. Returns an {@link AgentHandle} the owner
|
||||
* disposes to tear down exactly this agent.
|
||||
* @param options - agent id, caller-supplied session id, optional seed/meta,
|
||||
* and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle {
|
||||
// Check the agent id BEFORE preparing the session: register() would reject a
|
||||
@@ -168,6 +174,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* configured. NOT hard-injected (that would make non-persistent demos pend
|
||||
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
|
||||
* by the time this runs the service exists.
|
||||
* @param options - the persisted session id to reload, plus agent id/options.
|
||||
* @returns the handle for the agent resumed on the reconstructed session.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
|
||||
@@ -126,6 +126,8 @@ export class AgentRegistry extends Service {
|
||||
* Register the agent-creation factory (the loop calls this on construction,
|
||||
* effect-scoped). Throws if a factory is already registered. Returns the
|
||||
* disposer; on dispose the factory slot is cleared.
|
||||
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
|
||||
* @returns the disposer that clears the factory slot.
|
||||
*/
|
||||
setFactory(factory: AgentFactory): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
@@ -142,6 +144,8 @@ export class AgentRegistry extends Service {
|
||||
* agent): this constructs the agent and its session. Throws if no factory is
|
||||
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
|
||||
* down exactly this agent.
|
||||
* @param options - agent id, session id/seed/metadata, and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
create(options: CreateAgentOptions): AgentHandle {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
@@ -152,6 +156,8 @@ export class AgentRegistry extends Service {
|
||||
* Load a persisted session and resume an agent on it through the registered
|
||||
* factory. Rejects if no factory is registered; the factory rejects if
|
||||
* session persistence is not configured. Returns an {@link AgentHandle}.
|
||||
* @param options - the persisted session id plus agent id and options.
|
||||
* @returns the handle for the resumed agent.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
@@ -162,6 +168,8 @@ export class AgentRegistry extends Service {
|
||||
* Register a live agent. Throws if an agent with the same id is already
|
||||
* registered. Emits `agent/created` on registration and `agent/disposed`
|
||||
* when the calling fiber is disposed. Returns the disposer.
|
||||
* @param agent - the already-constructed agent to record in the store.
|
||||
* @returns the disposer that removes the agent and emits `agent/disposed`.
|
||||
*/
|
||||
register(agent: Agent): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||
@@ -200,10 +208,19 @@ export class AgentRegistry extends Service {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a live agent.
|
||||
* @param id - the agent id to look up.
|
||||
* @returns the agent, or undefined when no live agent has that id.
|
||||
*/
|
||||
get(id: AgentId): Agent | undefined {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* All live agents, in registration order.
|
||||
* @returns a fresh array; mutating it does not affect the registry.
|
||||
*/
|
||||
list(): Agent[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
|
||||
@@ -228,12 +228,14 @@ declare module 'cordis' {
|
||||
/**
|
||||
* An agent was registered in the {@link AgentRegistry} and is ready to
|
||||
* receive messages.
|
||||
* @param agent - the newly registered agent, already resolvable in the registry.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/created'(agent: Agent): void
|
||||
/**
|
||||
* An agent was disposed and removed from the registry; its fiber and any
|
||||
* in-flight turn have been torn down.
|
||||
* @param agent - the agent that was torn down; its handle is now inert.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/disposed'(agent: Agent): void
|
||||
@@ -241,12 +243,17 @@ declare module 'cordis' {
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
|
||||
* lifecycle off this transition, never off a status you just requested —
|
||||
* `send()` does not flip status to `running` before it returns.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/status'(agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* A message entered the agent's inbox (queued or steering). `source` is
|
||||
* the resolved source (defaults applied), not the caller's raw options.
|
||||
* @param agent - the agent whose inbox received the message.
|
||||
* @param content - the enqueued content blocks, verbatim.
|
||||
* @param info - the resolved source plus whether it entered as steering.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
@@ -260,6 +267,8 @@ declare module 'cordis' {
|
||||
* so via `agent.inject()` (a `context/message` the first request sees), not
|
||||
* by returning a decision. Cannot block the session from starting; that gap
|
||||
* is deliberate (a bridge logs/injects, it does not gate startup).
|
||||
* @param agent - the agent whose session lifecycle began.
|
||||
* @param source - why the session started (fresh startup, resume, …).
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/session-start'(agent: Agent, source: SessionStartSource): void
|
||||
@@ -295,6 +304,11 @@ declare module 'cordis' {
|
||||
* listener needs to measure pressure (the system prompt counts toward the
|
||||
* budget). `signal` cancels any in-flight work a listener starts (e.g. a
|
||||
* summarization model call).
|
||||
* @param agent - the agent about to open the step.
|
||||
* @param turn - the already-open turn this step belongs to.
|
||||
* @param step - the number of the step about to start.
|
||||
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
|
||||
* @param signal - aborts in-flight listener work when the turn is torn down.
|
||||
* @mode serial
|
||||
*/
|
||||
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
|
||||
@@ -310,6 +324,9 @@ declare module 'cordis' {
|
||||
* turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
|
||||
* Call `next()` to delegate to the default (allow unchanged), or return a
|
||||
* {@link PromptDecision} without calling `next()` to short-circuit.
|
||||
* @param agent - the agent draining its inbox.
|
||||
* @param content - the drained message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
@@ -319,12 +336,20 @@ declare module 'cordis' {
|
||||
* delegate, or return without it to short-circuit. For surface mutation that
|
||||
* must precede history derivation (compaction), use {@link agent/pre-step}
|
||||
* instead — by the time this fires, `options.messages` is already derived.
|
||||
* @param agent - the agent making the model call.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param options - the assembled request; listeners return a transformed copy.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
|
||||
/**
|
||||
* Waterfall: post-process the assembled assistant {@link Message} before
|
||||
* tool dispatch (validation, content rewriting, …).
|
||||
* @param agent - the agent that received the step's response.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step that produced the message.
|
||||
* @param message - the assistant message as assembled from the stream.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
@@ -335,6 +360,9 @@ declare module 'cordis' {
|
||||
* Listeners force-continue (`/goal`, `/loop` — optionally attaching a
|
||||
* `reason` recorded as next-step steering) or force-stop (budget guards).
|
||||
* Call `next()` to delegate to the default, or return a decision to override.
|
||||
* @param agent - the agent deciding whether to run another step.
|
||||
* @param turn - the turn being continued or stopped.
|
||||
* @param defaultDecision - what the loop would do absent an override.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
@@ -342,12 +370,20 @@ declare module 'cordis' {
|
||||
// ---- streaming + tool notifications (emit) ----
|
||||
/**
|
||||
* Steering content was injected into a running turn.
|
||||
* @param agent - the agent that absorbed the steering.
|
||||
* @param turn - the running turn that received it.
|
||||
* @param content - the injected blocks.
|
||||
* @param source - the steering message's resolved source.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
|
||||
/**
|
||||
* A step or turn errored. The loop reports a failure here (plus the logger)
|
||||
* even when the error has no in-turn position for a session `error` event.
|
||||
* @param agent - the agent whose turn errored.
|
||||
* @param turn - the turn in which the failure surfaced.
|
||||
* @param step - the step at which the failure surfaced.
|
||||
* @param error - the failure, verbatim.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI.
|
||||
* What a freshness diff CANNOT prove is that the generator REJECTS malformed
|
||||
* source the way it promises to — a missing `@mode` tag, or a tag that
|
||||
* contradicts the signature shape. These tests drive `collectEvents()` against
|
||||
* synthetic fixture packages to prove each guard fires (and that a well-formed
|
||||
* event passes), mirroring the drift-guard negative tests for verify-type-equiv.
|
||||
* source the way it promises to — a missing `@mode` tag, a tag that
|
||||
* contradicts the signature shape, or a JSDoc-completeness violation (missing
|
||||
* prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an
|
||||
* unannotated return type). These tests drive `collectEvents()` /
|
||||
* `collectServices()` against synthetic fixture packages to prove each guard
|
||||
* fires (and that well-formed declarations pass), mirroring the drift-guard
|
||||
* negative tests for verify-type-equiv.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
|
||||
/** Write a fixture package exposing one `interface Events` block and return the
|
||||
* scan root to hand `collectEvents`. */
|
||||
@@ -29,12 +32,31 @@ function fixtureRoot(eventsBlock: string): string {
|
||||
return root
|
||||
}
|
||||
|
||||
/** Write a fixture package exposing one `interface Context` entry (`ctx.fix` →
|
||||
* `FixService`) plus the class source, and return the scan root to hand
|
||||
* `collectServices`. */
|
||||
function serviceFixtureRoot(classSource: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
|
||||
const dir = join(root, 'packages', 'group', 'fix', 'src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'index.ts'),
|
||||
`declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`,
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
const make = (block: string): string => {
|
||||
const r = fixtureRoot(block)
|
||||
roots.push(r)
|
||||
return r
|
||||
}
|
||||
const makeService = (classSource: string): string => {
|
||||
const r = serviceFixtureRoot(classSource)
|
||||
roots.push(r)
|
||||
return r
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
@@ -43,7 +65,7 @@ afterEach(() => {
|
||||
describe('gen-cordis-catalog collectEvents', () => {
|
||||
it('extracts a well-formed event with its @mode and JSDoc', () => {
|
||||
const events = collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' })
|
||||
@@ -51,7 +73,7 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
|
||||
it('classifies a trailing-next signature as a waterfall', () => {
|
||||
const events = collectEvents(make(
|
||||
' /**\n * Intercept it.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
|
||||
' /**\n * Intercept it.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
|
||||
))
|
||||
expect(events[0]?.mode).toBe('waterfall')
|
||||
})
|
||||
@@ -65,19 +87,154 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
|
||||
it('hard-errors when an event is missing its @mode tag', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /** No mode here. */\n \'fix/untagged\'(id: string): void',
|
||||
' /** No mode here. */\n \'fix/untagged\'(): void',
|
||||
))).toThrow(/missing an @mode tag/)
|
||||
})
|
||||
|
||||
it('hard-errors when @mode contradicts a trailing-next (waterfall) shape', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * Mislabeled.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
|
||||
' /**\n * Mislabeled.\n * @param x - the value.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
|
||||
))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/)
|
||||
})
|
||||
|
||||
it('hard-errors when @mode waterfall has no trailing next to delegate to', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * Not actually a waterfall.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void',
|
||||
' /**\n * Not actually a waterfall.\n * @param id - which thing.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void',
|
||||
))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/)
|
||||
})
|
||||
|
||||
it('hard-errors on an undocumented payload parameter', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))).toThrow(/is missing @param id/)
|
||||
})
|
||||
|
||||
it('hard-errors on a stale @param naming no real parameter', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @param id - which thing.\n * @param ghost - not a parameter.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))).toThrow(/@param ghost does not match any parameter/)
|
||||
})
|
||||
|
||||
it('hard-errors on an @param with an empty description', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @param id\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))).toThrow(/@param id has an empty description/)
|
||||
})
|
||||
|
||||
it('hard-errors on an event whose JSDoc has no description prose', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
))).toThrow(/no description prose/)
|
||||
})
|
||||
|
||||
it('exempts the `this` receiver and the trailing waterfall `next` from @param', () => {
|
||||
const events = collectEvents(make(
|
||||
' /**\n * Scoped interception.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/scoped\'(this: object, x: number, next: () => Promise<number>): Promise<number>',
|
||||
))
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hard-errors on a binding-pattern parameter @param cannot name', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/destructured\'({ id }: { id: string }): void',
|
||||
))).toThrow(/is a binding pattern/)
|
||||
})
|
||||
|
||||
it('aggregates every violation into one error instead of failing fast', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /** First. */\n \'fix/one\'(): void\n /** Second. */\n \'fix/two\'(): void',
|
||||
))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-cordis-catalog collectServices', () => {
|
||||
const WELL_FORMED = `/** Fixture service. */
|
||||
export class FixService {
|
||||
/**
|
||||
* Do the thing.
|
||||
* @param id - which thing to do.
|
||||
* @returns the outcome of doing it.
|
||||
*/
|
||||
run(id: string): string { return id }
|
||||
|
||||
/** Fire and forget (void needs no @returns). */
|
||||
poke(): void {}
|
||||
|
||||
/** Flush (Promise<void> needs no @returns either). */
|
||||
flush(): Promise<void> { return Promise.resolve() }
|
||||
}`
|
||||
|
||||
it('extracts a well-formed service with its methods and class JSDoc', () => {
|
||||
const services = collectServices(makeService(WELL_FORMED))
|
||||
expect(services).toHaveLength(1)
|
||||
expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' })
|
||||
expect(services[0]?.methods).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('hard-errors on a public method with no JSDoc at all', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/ctx\.fix\.run .* has no JSDoc/)
|
||||
})
|
||||
|
||||
it('hard-errors on an undocumented method parameter', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/ctx\.fix\.run .* is missing @param id/)
|
||||
})
|
||||
|
||||
it('hard-errors on a missing @returns for a non-void return type', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/is missing @returns \(return type: string\)/)
|
||||
})
|
||||
|
||||
it('hard-errors on an unannotated (inferred) return type', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}',
|
||||
))).toThrow(/no return type annotation/)
|
||||
})
|
||||
|
||||
it('hard-errors on a service class with no JSDoc', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'export class FixService {\n /** Fire and forget. */\n poke(): void {}\n}',
|
||||
))).toThrow(/class FixService has no JSDoc/)
|
||||
})
|
||||
|
||||
it('hard-errors on a stale method @param', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param ghost - not a parameter.\n */\n poke(): void {}\n}',
|
||||
))).toThrow(/@param ghost does not match any parameter/)
|
||||
})
|
||||
|
||||
it('hard-errors on a method whose JSDoc is tags with no description prose', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * @param id - which thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/no description prose above its block tags/)
|
||||
})
|
||||
|
||||
it('hard-errors on a method @param with an empty description', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param id\n */\n poke(id: string): void {}\n}',
|
||||
))).toThrow(/@param id has an empty description/)
|
||||
})
|
||||
|
||||
it('hard-errors on an @returns with an empty description', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n * @returns\n */\n run(id: string): string { return id }\n}',
|
||||
))).toThrow(/@returns has an empty description/)
|
||||
})
|
||||
|
||||
it('hard-errors on a binding-pattern method parameter @param cannot name', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n */\n run({ id }: { id: string }): void {}\n}',
|
||||
))).toThrow(/is a binding pattern/)
|
||||
})
|
||||
|
||||
it('ignores private/protected/static members (not the ctx.<key> surface)', () => {
|
||||
const services = collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n private hidden(id: string): string { return id }\n protected hook(): void {}\n static helper(): void {}\n}',
|
||||
))
|
||||
expect(services[0]?.methods).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,12 +30,15 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A session was created in the store.
|
||||
* @param session - the session just entered and announced.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/created'(session: Session): void
|
||||
/**
|
||||
* An event was appended to a session log (sync, fire-and-forget). This is
|
||||
* the per-append feed a UI or invariant plugin tails.
|
||||
* @param session - the session whose log grew.
|
||||
* @param event - the appended event, exactly as recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/event'(session: Session, event: SessionEvent): void
|
||||
@@ -45,6 +48,7 @@ declare module 'cordis' {
|
||||
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
|
||||
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
|
||||
* and the loop waits for all of them, but none can veto.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @mode parallel
|
||||
*/
|
||||
'session/flush'(session: Session): Promise<void> | void
|
||||
@@ -342,6 +346,9 @@ export class SessionStore extends Service {
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
|
||||
* `startOwned`).
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @returns the live session, already entered and announced.
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* non-absolute path (storage backends key directories off it).
|
||||
*/
|
||||
@@ -367,6 +374,9 @@ export class SessionStore extends Service {
|
||||
* chain rather than as racing sibling effects — which would detach `onAppend`
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @returns the constructed session, NOT yet in the store.
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* non-absolute path.
|
||||
*/
|
||||
@@ -404,6 +414,8 @@ export class SessionStore extends Service {
|
||||
* the two back-to-back so they never trip this, but the public seam cannot
|
||||
* assume that.
|
||||
*
|
||||
* @param session - a {@link prepare}d session not yet in the store.
|
||||
* @returns the detach disposer (`onAppend = undefined` + store removal).
|
||||
* @throws if a session with this id is already in the store.
|
||||
*/
|
||||
enter(session: Session): () => void {
|
||||
@@ -418,15 +430,25 @@ export class SessionStore extends Service {
|
||||
|
||||
/** Emit `session/created` for an {@link enter}ed session. Separate from
|
||||
* {@link enter} so the caller can yield the detach disposer first (rollback
|
||||
* safety — see {@link enter}). */
|
||||
* safety — see {@link enter}).
|
||||
* @param session - the entered session to announce to listeners. */
|
||||
announce(session: Session): void {
|
||||
this.ctx.emit('session/created', session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a live session.
|
||||
* @param id - the session id to look up.
|
||||
* @returns the session, or undefined when no live session has that id.
|
||||
*/
|
||||
get(id: SessionId): Session | undefined {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* All live sessions, in creation order.
|
||||
* @returns a fresh array; mutating it does not affect the store.
|
||||
*/
|
||||
list(): Session[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ declare module 'cordis' {
|
||||
* Waterfall around prompt assembly — mutate or extend the
|
||||
* {@link PromptAssembly} (sections + tool schemas) before it is rendered.
|
||||
* Bound to the {@link SystemPrompt} service; call `next()` to delegate.
|
||||
* @param assembly - the assembly built from the registered sections and
|
||||
* tool providers; listeners may mutate it or return a replacement.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
@@ -80,6 +82,8 @@ export class SystemPrompt extends Service {
|
||||
* Contribute a text section to the system prompt. Order is determined by
|
||||
* `section.order` (ascending). The section is removed when the calling
|
||||
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
|
||||
* @param section - the section to contribute (name, order, text or provider).
|
||||
* @returns the disposer that removes the section.
|
||||
*/
|
||||
section(section: PromptSection): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
@@ -105,6 +109,8 @@ export class SystemPrompt extends Service {
|
||||
* Contribute a tool-schema provider that is evaluated at each assembly
|
||||
* call (so it can reflect the live registry state). The provider is
|
||||
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
|
||||
* @param provider - evaluated at every {@link assemble} for fresh schemas.
|
||||
* @returns the disposer that removes the provider.
|
||||
*/
|
||||
tools(provider: () => ToolSchema[]): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
@@ -132,6 +138,7 @@ export class SystemPrompt extends Service {
|
||||
* listeners the opportunity to mutate or replace the assembly before it
|
||||
* reaches the model. Await the result before reading the assembly values —
|
||||
* waterfall listeners may be async.
|
||||
* @returns the assembly after the waterfall has run.
|
||||
*/
|
||||
assemble(): Promise<PromptAssembly> {
|
||||
const assembly: PromptAssembly = {
|
||||
|
||||
@@ -60,6 +60,7 @@ declare module 'cordis' {
|
||||
* tool body never runs. Input rewrite is deliberately NOT offered here (see
|
||||
* {@link PreToolDecision}); `ask` degrades to deny until the permission
|
||||
* system lands (`FIXME(permissions)`).
|
||||
* @param exec - the pending call (name, parsed arguments, caller agent).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
@@ -74,6 +75,8 @@ declare module 'cordis' {
|
||||
* `execute`'s outer try/catch (and the tool body keeps its own inner
|
||||
* try/catch, so a thrown tool still reaches `post-execute` as an `isError`
|
||||
* result).
|
||||
* @param exec - the call that just ran (name, parsed arguments, caller agent).
|
||||
* @param result - the dispatch outcome a listener may accept, replace, or block.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
@@ -277,6 +280,9 @@ export class ToolRegistry extends Service {
|
||||
* registered. The tool's schema (minus the `execute` function) is
|
||||
* automatically contributed to the system-prompt assembly. Disposed
|
||||
* with the calling fiber. Emits `tools/change` on register/unregister.
|
||||
* @param definition - the tool's schema plus its execute (and optional
|
||||
* presentation) functions.
|
||||
* @returns the disposer that unregisters the tool.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
@@ -300,6 +306,11 @@ export class ToolRegistry extends Service {
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a registered tool.
|
||||
* @param name - the tool name as registered.
|
||||
* @returns the definition, or undefined when no tool has that name.
|
||||
*/
|
||||
get(name: string): ToolDefinition | undefined {
|
||||
return this.store.get(name)
|
||||
}
|
||||
@@ -313,6 +324,7 @@ export class ToolRegistry extends Service {
|
||||
* those (especially the functions) must never leak into a model request. An
|
||||
* allowlist can't drift when a new non-schema member is added to the
|
||||
* definition; a denylist (rest-destructure) would silently leak it.
|
||||
* @returns one deep-cloned schema per registered tool, in registration order.
|
||||
*/
|
||||
schemas(): ToolSchema[] {
|
||||
return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
|
||||
@@ -334,6 +346,9 @@ export class ToolRegistry extends Service {
|
||||
* still inspect. If the tool is not registered, the result is an `isError`
|
||||
* carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
|
||||
* surfaces its `{ name, code }` on the result.
|
||||
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
|
||||
* @returns the final result after both waterfalls; failures resolve as
|
||||
* `isError` results, never rejections.
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user