Add ESLint: typescript-eslint strict-type-checked + stylistic formatting
Flat config with two layers. Correctness (type-checked): the headline rules for this codebase are no-floating-promises / no-misused-promises (a lost promise in the agent loop is our primary bug class), switch-exhaustiveness-check (we switch over merge-extensible unions everywhere), no-unnecessary-condition, require-await, and no-explicit-any. Style (@stylistic): 2-space, no semicolons, single quotes, trailing commas, max-len 140 — the existing house style, now enforced instead of drifting between agents. vendor/ is excluded (vendored source keeps upstream style); tests relax the rules that fight test ergonomics (non-null assertions after expects, async mock signatures, non-Error throws). Code adjusted to pass: registry disposers wrap ctx.effect's promise-returning disposer behind a sync () => void (our public API), BlockAssembler gains an invariant-checking mustGet instead of non-null assertions, lastTurnNumber uses findLast, waterfall tails return Promise.resolve instead of async-without-await arrows, and the two deliberate suppressions (non-exhaustive derivation switch, unbound execute pass-through) carry justification comments. yarn lint / yarn lint:fix added.
This commit is contained in:
133
eslint.config.mjs
Normal file
133
eslint.config.mjs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import stylistic from '@stylistic/eslint-plugin'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ESLint flat config. Two layers:
|
||||||
|
*
|
||||||
|
* 1. typescript-eslint strict-type-checked — correctness rules that need the
|
||||||
|
* type checker. The headline rules for this codebase: no-floating-promises
|
||||||
|
* and no-misused-promises (an un-awaited promise in the agent loop is our
|
||||||
|
* primary bug class), switch-exhaustiveness-check (we switch over
|
||||||
|
* merge-extensible unions everywhere).
|
||||||
|
* 2. @stylistic — formatting (2-space, no semicolons, single quotes, trailing
|
||||||
|
* commas), so style is enforced rather than drifting between agents.
|
||||||
|
*
|
||||||
|
* vendor/ is linted lightly (style only stays OFF — vendored code keeps
|
||||||
|
* upstream style; only a few safety rules apply there) and examples/tests are
|
||||||
|
* linted with relaxed unsafe-* rules where mocks intentionally bend types.
|
||||||
|
*/
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'**/lib/**',
|
||||||
|
'**/node_modules/**',
|
||||||
|
'vendor/**', // vendored source keeps upstream style and idioms
|
||||||
|
'**/*.js',
|
||||||
|
'**/*.mjs',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- our packages: full strictness -------------------------------------
|
||||||
|
{
|
||||||
|
files: ['packages/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'],
|
||||||
|
extends: [
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.typecheck.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// The bug class this repo cares most about: lost promises in the loop.
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'error',
|
||||||
|
'@typescript-eslint/require-await': 'error',
|
||||||
|
'@typescript-eslint/switch-exhaustiveness-check': ['error', {
|
||||||
|
considerDefaultExhaustiveForUnions: true,
|
||||||
|
}],
|
||||||
|
'@typescript-eslint/no-unnecessary-condition': ['error', {
|
||||||
|
allowConstantLoopConditions: true,
|
||||||
|
}],
|
||||||
|
// `any` requires a justification comment — enforced as: no bare casts.
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
// Style points where the codebase intentionally diverges from preset:
|
||||||
|
'@typescript-eslint/no-namespace': 'off', // Cordis Config-namespace idiom
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'off', // merge-extensible maps
|
||||||
|
'@typescript-eslint/no-invalid-void-type': 'off', // event signatures
|
||||||
|
'@typescript-eslint/restrict-template-expressions': ['error', {
|
||||||
|
allowNumber: true,
|
||||||
|
allowBoolean: true,
|
||||||
|
}],
|
||||||
|
// `void foo()` in arrow listeners is our idiom for intentional fire-and-forget
|
||||||
|
'no-void': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- examples: demo code conforms to async interfaces without awaiting ---
|
||||||
|
{
|
||||||
|
files: ['examples/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/require-await': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- tests: same rules, minus the friction that fights test ergonomics --
|
||||||
|
{
|
||||||
|
files: ['packages/*/tests/**/*.ts'],
|
||||||
|
extends: [
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.typecheck.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'error',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off', // assertions follow expect()s
|
||||||
|
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||||
|
'@typescript-eslint/require-await': 'off', // mock execute() signatures
|
||||||
|
'@typescript-eslint/no-empty-function': 'off', // stub agents
|
||||||
|
'@typescript-eslint/only-throw-error': 'off', // testing non-Error throws
|
||||||
|
'@typescript-eslint/no-namespace': 'off',
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
|
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- formatting (everything we own) -------------------------------------
|
||||||
|
{
|
||||||
|
files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'eslint.config.mjs'],
|
||||||
|
plugins: { '@stylistic': stylistic },
|
||||||
|
rules: {
|
||||||
|
'@stylistic/indent': ['error', 2],
|
||||||
|
'@stylistic/semi': ['error', 'never'],
|
||||||
|
'@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
|
||||||
|
'@stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||||
|
'@stylistic/eol-last': ['error', 'always'],
|
||||||
|
'@stylistic/no-trailing-spaces': 'error',
|
||||||
|
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||||
|
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
|
||||||
|
'@stylistic/member-delimiter-style': ['error', {
|
||||||
|
multiline: { delimiter: 'none' },
|
||||||
|
singleline: { delimiter: 'semi', requireLast: false },
|
||||||
|
}],
|
||||||
|
'@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -55,6 +55,6 @@ export function apply(ctx: Context) {
|
|||||||
setTimeout(() => process.exit(0), 200)
|
setTimeout(() => process.exit(0), 200)
|
||||||
})
|
})
|
||||||
process.stdout.write('echo-agent ready. Type a message ("echo <text>" triggers the tool).\n> ')
|
process.stdout.write('echo-agent ready. Type a message ("echo <text>" triggers the tool).\n> ')
|
||||||
return () => reader.close()
|
return () => { reader.close() }
|
||||||
}, 'stdio-chat')
|
}, 'stdio-chat')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b tsconfig.build.json && tsx scripts/build.ts",
|
"build": "tsc -b tsconfig.build.json && tsx scripts/build.ts",
|
||||||
"typecheck": "tsc -b tsconfig.build.json && tsc -p tsconfig.typecheck.json",
|
"typecheck": "tsc -b tsconfig.build.json && tsc -p tsconfig.typecheck.json",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"demo": "node --expose-internals --import tsx examples/echo-agent/start.ts"
|
"demo": "node --expose-internals --import tsx examples/echo-agent/start.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.3.5",
|
||||||
"dumble": "^0.2.3",
|
"dumble": "^0.2.3",
|
||||||
|
"eslint": "^10.4.1",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.61.0",
|
||||||
"vite-tsconfig-paths": "^6.1.1",
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class LoopAgent implements Agent {
|
|||||||
|
|
||||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||||
if (this._status !== 'running') return this.send(content, options)
|
if (this._status !== 'running') { this.send(content, options); return }
|
||||||
const source = this.resolveSource(options)
|
const source = this.resolveSource(options)
|
||||||
this.inbox.steer({ content, source })
|
this.inbox.steer({ content, source })
|
||||||
this.ctx.emit('agent/queued', this, content, { source, steering: true })
|
this.ctx.emit('agent/queued', this, content, { source, steering: true })
|
||||||
@@ -88,7 +88,7 @@ export class LoopAgent implements Agent {
|
|||||||
*/
|
*/
|
||||||
start(): () => void {
|
start(): () => void {
|
||||||
this.done = runLoop(this.ctx, this, {
|
this.done = runLoop(this.ctx, this, {
|
||||||
setStatus: status => this.setStatus(status),
|
setStatus: (status) => { this.setStatus(status) },
|
||||||
setAbort: controller => void (this.currentAbort = controller),
|
setAbort: controller => void (this.currentAbort = controller),
|
||||||
disposed: this.disposed,
|
disposed: this.disposed,
|
||||||
isDisposed: () => this._status === 'disposed',
|
isDisposed: () => this._status === 'disposed',
|
||||||
|
|||||||
@@ -113,7 +113,9 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
|
|||||||
|
|
||||||
// Drain queued messages into the session — they trigger this turn.
|
// Drain queued messages into the session — they trigger this turn.
|
||||||
const queued = agent.inbox.drainQueued()
|
const queued = agent.inbox.drainQueued()
|
||||||
const trigger: TurnTrigger = { kind: 'message', source: queued[0]!.source }
|
const first = queued[0]
|
||||||
|
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||||
|
const trigger: TurnTrigger = { kind: 'message', source: first.source }
|
||||||
for (const message of queued) {
|
for (const message of queued) {
|
||||||
session.append('user/message', { content: message.content, source: message.source })
|
session.append('user/message', { content: message.content, source: message.source })
|
||||||
}
|
}
|
||||||
@@ -177,7 +179,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
|
|||||||
try {
|
try {
|
||||||
shouldContinue = await ctx.waterfall(
|
shouldContinue = await ctx.waterfall(
|
||||||
'agent/turn-continuation', agent, turn, defaultDecision,
|
'agent/turn-continuation', agent, turn, defaultDecision,
|
||||||
async () => defaultDecision,
|
() => Promise.resolve(defaultDecision),
|
||||||
)
|
)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// A broken continuation plugin ends the turn, not the loop.
|
// A broken continuation plugin ends the turn, not the loop.
|
||||||
@@ -246,7 +248,7 @@ async function runStep(
|
|||||||
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
||||||
signal,
|
signal,
|
||||||
}
|
}
|
||||||
request = await ctx.waterfall('agent/request', agent, turn, step, request, async () => request)
|
request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))
|
||||||
if (!request.model) {
|
if (!request.model) {
|
||||||
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
||||||
}
|
}
|
||||||
@@ -264,7 +266,7 @@ async function runStep(
|
|||||||
// source of truth for derived history and replay) records the message that
|
// source of truth for derived history and replay) records the message that
|
||||||
// tool dispatch actually uses.
|
// tool dispatch actually uses.
|
||||||
let message: Message = assembler.message()
|
let message: Message = assembler.message()
|
||||||
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, async () => message)
|
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
|
||||||
|
|
||||||
session.append('assistant/message', { turn, step, content: message.content })
|
session.append('assistant/message', { turn, step, content: message.content })
|
||||||
if (assembler.usage) {
|
if (assembler.usage) {
|
||||||
@@ -297,6 +299,9 @@ async function runStep(
|
|||||||
content: result.content,
|
content: result.content,
|
||||||
isError: result.isError,
|
isError: result.isError,
|
||||||
})
|
})
|
||||||
|
// signal CAN flip during the await above (abort() inside a tool);
|
||||||
|
// the analyzer can't see through the await boundary.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,9 +310,6 @@ async function runStep(
|
|||||||
|
|
||||||
/** The last turn number in a (possibly seeded) session log, or 0. */
|
/** The last turn number in a (possibly seeded) session log, or 0. */
|
||||||
function lastTurnNumber(session: Session): number {
|
function lastTurnNumber(session: Session): number {
|
||||||
for (let index = session.events.length - 1; index >= 0; index--) {
|
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||||
const event = session.events[index]!
|
return lastStart?.data.turn ?? 0
|
||||||
if (event.type === 'turn/start') return event.data.turn
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService, { StreamChunk, ToolResultBlock } from '@deepseek-ai/dsh-llm'
|
import LlmService, { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore, { SessionEventType, TurnEndReason } from '@deepseek-ai/dsh-session'
|
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||||
@@ -99,7 +99,7 @@ describe('agent loop', () => {
|
|||||||
expect(toolResultMessage).toBeDefined()
|
expect(toolResultMessage).toBeDefined()
|
||||||
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
|
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
|
||||||
expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
|
expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
|
||||||
expect((block as ToolResultBlock).content).toEqual([{ type: 'text', text: 'echo: ping' }])
|
expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }])
|
||||||
|
|
||||||
// session log records call + result
|
// session log records call + result
|
||||||
const types = agent.session.events.map(e => e.type)
|
const types = agent.session.events.map(e => e.type)
|
||||||
@@ -380,7 +380,7 @@ describe('agent loop', () => {
|
|||||||
|
|
||||||
expect(agent.status).toBe('disposed')
|
expect(agent.status).toBe('disposed')
|
||||||
expect(ctx.agents.get('scoped')).toBeUndefined()
|
expect(ctx.agents.get('scoped')).toBeUndefined()
|
||||||
expect(() => send(agent, 'too late')).toThrow('disposed')
|
expect(() => { send(agent, 'too late') }).toThrow('disposed')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replays a session log into an identical derived history', async () => {
|
it('replays a session log into an identical derived history', async () => {
|
||||||
@@ -405,6 +405,6 @@ describe('agent loop', () => {
|
|||||||
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
||||||
// event-by-event identity of types
|
// event-by-event identity of types
|
||||||
expect(replayed.events.map(e => e.type)).toEqual(
|
expect(replayed.events.map(e => e.type)).toEqual(
|
||||||
agent.session.events.map(e => e.type as SessionEventType))
|
agent.session.events.map(e => e.type))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|||||||
export function textResponse(text: string): StreamChunk[] {
|
export function textResponse(text: string): StreamChunk[] {
|
||||||
return [
|
return [
|
||||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||||
...[...text].map((char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
||||||
{ type: 'finish', reason: { kind: 'stop' } },
|
{ type: 'finish', reason: { kind: 'stop' } },
|
||||||
@@ -60,8 +60,8 @@ export class MockAdapter extends LlmAdapter {
|
|||||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||||
await new Promise<void>((_resolve, reject) => {
|
await new Promise<void>((_resolve, reject) => {
|
||||||
if (options.signal?.aborted) return reject(new Error('aborted'))
|
if (options.signal?.aborted) { reject(new Error('aborted')); return }
|
||||||
options.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class AgentRegistry extends Service {
|
|||||||
* when the calling fiber is disposed. Returns the disposer.
|
* when the calling fiber is disposed. Returns the disposer.
|
||||||
*/
|
*/
|
||||||
register(agent: Agent): () => void {
|
register(agent: Agent): () => void {
|
||||||
return this.ctx.effect(() => {
|
const dispose = this.ctx.effect(() => {
|
||||||
if (this.store.has(agent.id)) {
|
if (this.store.has(agent.id)) {
|
||||||
throw new Error(`agent "${agent.id}" is already registered`)
|
throw new Error(`agent "${agent.id}" is already registered`)
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,9 @@ export class AgentRegistry extends Service {
|
|||||||
this.ctx.emit('agent/disposed', agent)
|
this.ctx.emit('agent/disposed', agent)
|
||||||
}
|
}
|
||||||
}, 'agents.register()')
|
}, 'agents.register()')
|
||||||
|
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||||
|
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||||
|
return () => void dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): Agent | undefined {
|
get(id: string): Agent | undefined {
|
||||||
|
|||||||
@@ -109,9 +109,16 @@ export class BlockAssembler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invariant accessor: every index in `order` has a partial. */
|
||||||
|
private mustGet(index: number): PartialBlock {
|
||||||
|
const partial = this.partials.get(index)
|
||||||
|
if (!partial) throw new Error(`BlockAssembler invariant violated: no partial for index ${index}`)
|
||||||
|
return partial
|
||||||
|
}
|
||||||
|
|
||||||
/** Assemble all blocks seen so far, in stream order. */
|
/** Assemble all blocks seen so far, in stream order. */
|
||||||
blocks(): ContentBlock[] {
|
blocks(): ContentBlock[] {
|
||||||
return this.order.map(index => this.assemble(this.partials.get(index)!, index))
|
return this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,8 +130,9 @@ export class BlockAssembler {
|
|||||||
flushReady(): ContentBlock[] {
|
flushReady(): ContentBlock[] {
|
||||||
const ready: ContentBlock[] = []
|
const ready: ContentBlock[] = []
|
||||||
while (this.flushed < this.order.length) {
|
while (this.flushed < this.order.length) {
|
||||||
const index = this.order[this.flushed]!
|
const index = this.order[this.flushed]
|
||||||
const partial = this.partials.get(index)!
|
if (index === undefined) break
|
||||||
|
const partial = this.mustGet(index)
|
||||||
if (!partial.block) break
|
if (!partial.block) break
|
||||||
ready.push(partial.block)
|
ready.push(partial.block)
|
||||||
this.flushed += 1
|
this.flushed += 1
|
||||||
@@ -141,8 +149,9 @@ export class BlockAssembler {
|
|||||||
flushRemaining(): ContentBlock[] {
|
flushRemaining(): ContentBlock[] {
|
||||||
const remaining: ContentBlock[] = []
|
const remaining: ContentBlock[] = []
|
||||||
while (this.flushed < this.order.length) {
|
while (this.flushed < this.order.length) {
|
||||||
const index = this.order[this.flushed]!
|
const index = this.order[this.flushed]
|
||||||
remaining.push(this.assemble(this.partials.get(index)!, index))
|
if (index === undefined) break
|
||||||
|
remaining.push(this.assemble(this.mustGet(index), index))
|
||||||
this.flushed += 1
|
this.flushed += 1
|
||||||
}
|
}
|
||||||
return remaining
|
return remaining
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class LlmService extends Service {
|
|||||||
* fiber.
|
* fiber.
|
||||||
*/
|
*/
|
||||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
||||||
return this.ctx.effect(() => {
|
const dispose = this.ctx.effect(() => {
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
if (this.adapters.has(model)) {
|
if (this.adapters.has(model)) {
|
||||||
throw new LlmError(`an adapter for model "${model}" is already registered`, 'DUPLICATE_ADAPTER')
|
throw new LlmError(`an adapter for model "${model}" is already registered`, 'DUPLICATE_ADAPTER')
|
||||||
@@ -82,6 +82,9 @@ export class LlmService extends Service {
|
|||||||
this.ctx.emit('llm/adapter-change')
|
this.ctx.emit('llm/adapter-change')
|
||||||
}
|
}
|
||||||
}, 'llm.registerAdapter()')
|
}, 'llm.registerAdapter()')
|
||||||
|
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||||
|
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||||
|
return () => void dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Model names with a registered adapter. */
|
/** Model names with a registered adapter. */
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ export class Session {
|
|||||||
deriveMessages(): Message[] {
|
deriveMessages(): Message[] {
|
||||||
const messages: Message[] = []
|
const messages: Message[] = []
|
||||||
for (const event of this.log) {
|
for (const event of this.log) {
|
||||||
|
// Intentionally non-exhaustive: only message-producing events derive
|
||||||
|
// history; turn/step boundaries, chunks, usage, and errors are
|
||||||
|
// trace/replay data.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'user/message': {
|
case 'user/message': {
|
||||||
messages.push({ role: 'user', content: event.data.content })
|
messages.push({ role: 'user', content: event.data.content })
|
||||||
@@ -155,7 +159,7 @@ export class SessionStore extends Service {
|
|||||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||||
const session = new Session(id, seed)
|
const session = new Session(id, seed)
|
||||||
this.ctx.effect(() => {
|
this.ctx.effect(() => {
|
||||||
session.onAppend = (event) => this.ctx.emit('session/event', session, event)
|
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
|
||||||
this.store.set(id, session)
|
this.store.set(id, session)
|
||||||
this.ctx.emit('session/created', session)
|
this.ctx.emit('session/created', session)
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export class SystemPrompt extends Service {
|
|||||||
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
|
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
|
||||||
*/
|
*/
|
||||||
section(section: PromptSection): () => void {
|
section(section: PromptSection): () => void {
|
||||||
return this.ctx.effect(() => {
|
const dispose = this.ctx.effect(() => {
|
||||||
this.sections.push(section)
|
this.sections.push(section)
|
||||||
this.ctx.emit('system-prompt/change')
|
this.ctx.emit('system-prompt/change')
|
||||||
return () => {
|
return () => {
|
||||||
@@ -82,6 +82,9 @@ export class SystemPrompt extends Service {
|
|||||||
this.ctx.emit('system-prompt/change')
|
this.ctx.emit('system-prompt/change')
|
||||||
}
|
}
|
||||||
}, 'systemPrompt.section()')
|
}, 'systemPrompt.section()')
|
||||||
|
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||||
|
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||||
|
return () => void dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,7 +93,7 @@ export class SystemPrompt extends Service {
|
|||||||
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
|
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
|
||||||
*/
|
*/
|
||||||
tools(provider: () => ToolSchema[]): () => void {
|
tools(provider: () => ToolSchema[]): () => void {
|
||||||
return this.ctx.effect(() => {
|
const dispose = this.ctx.effect(() => {
|
||||||
this.toolProviders.push(provider)
|
this.toolProviders.push(provider)
|
||||||
this.ctx.emit('system-prompt/change')
|
this.ctx.emit('system-prompt/change')
|
||||||
return () => {
|
return () => {
|
||||||
@@ -99,6 +102,9 @@ export class SystemPrompt extends Service {
|
|||||||
this.ctx.emit('system-prompt/change')
|
this.ctx.emit('system-prompt/change')
|
||||||
}
|
}
|
||||||
}, 'systemPrompt.tools()')
|
}, 'systemPrompt.tools()')
|
||||||
|
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||||
|
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||||
|
return () => void dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,7 +119,7 @@ export class SystemPrompt extends Service {
|
|||||||
sections: [...this.sections].sort((a, b) => a.order - b.order),
|
sections: [...this.sections].sort((a, b) => a.order - b.order),
|
||||||
tools: this.toolProviders.flatMap(provider => provider()),
|
tools: this.toolProviders.flatMap(provider => provider()),
|
||||||
}
|
}
|
||||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, async () => assembly)
|
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, () => Promise.resolve(assembly))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('SystemPrompt', () => {
|
|||||||
await ctx.plugin(SystemPrompt)
|
await ctx.plugin(SystemPrompt)
|
||||||
|
|
||||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Code.' })
|
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Code.' })
|
||||||
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => `cwd: /tmp` })
|
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
|
||||||
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
|
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
|
||||||
ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
|
ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export class ToolRegistry extends Service {
|
|||||||
* with the calling fiber. Emits `tools/change` on register/unregister.
|
* with the calling fiber. Emits `tools/change` on register/unregister.
|
||||||
*/
|
*/
|
||||||
register(definition: ToolDefinition): () => void {
|
register(definition: ToolDefinition): () => void {
|
||||||
return this.ctx.effect(() => {
|
const dispose = this.ctx.effect(() => {
|
||||||
if (this.store.has(definition.name)) {
|
if (this.store.has(definition.name)) {
|
||||||
throw new Error(`tool "${definition.name}" is already registered`)
|
throw new Error(`tool "${definition.name}" is already registered`)
|
||||||
}
|
}
|
||||||
@@ -118,6 +118,9 @@ export class ToolRegistry extends Service {
|
|||||||
this.ctx.emit('tools/change')
|
this.ctx.emit('tools/change')
|
||||||
}
|
}
|
||||||
}, 'tools.register()')
|
}, 'tools.register()')
|
||||||
|
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||||
|
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||||
|
return () => void dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
get(name: string): ToolDefinition | undefined {
|
get(name: string): ToolDefinition | undefined {
|
||||||
@@ -130,6 +133,8 @@ export class ToolRegistry extends Service {
|
|||||||
* assembly.
|
* assembly.
|
||||||
*/
|
*/
|
||||||
schemas(): ToolSchema[] {
|
schemas(): ToolSchema[] {
|
||||||
|
// Rest-destructure to drop `execute`; the unused binding is the idiom.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/no-unused-vars
|
||||||
return [...this.store.values()].map(({ execute, ...schema }) => schema)
|
return [...this.store.values()].map(({ execute, ...schema }) => schema)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ export type SchemaSpec = Record<string, SchemaProp>
|
|||||||
/** Map a {@link SchemaType} to its TS primitive type. */
|
/** Map a {@link SchemaType} to its TS primitive type. */
|
||||||
type TypeOf<T extends SchemaType> =
|
type TypeOf<T extends SchemaType> =
|
||||||
T extends 'string' ? string :
|
T extends 'string' ? string :
|
||||||
T extends 'number' ? number :
|
T extends 'number' ? number :
|
||||||
T extends 'boolean' ? boolean :
|
T extends 'boolean' ? boolean :
|
||||||
T extends 'object' ? Record<string, unknown> :
|
T extends 'object' ? Record<string, unknown> :
|
||||||
T extends 'array' ? unknown[] :
|
T extends 'array' ? unknown[] :
|
||||||
never
|
never
|
||||||
|
|
||||||
/** Flatten an intersection into one object type for readable hovers. */
|
/** Flatten an intersection into one object type for readable hovers. */
|
||||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||||
@@ -82,8 +82,8 @@ type RequiredKeys<S extends SchemaSpec> =
|
|||||||
*/
|
*/
|
||||||
type InferPropValue<P extends SchemaProp> =
|
type InferPropValue<P extends SchemaProp> =
|
||||||
P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
|
P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
|
||||||
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
|
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
|
||||||
TypeOf<P['type']>
|
TypeOf<P['type']>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infer the TS argument type for a complete {@link SchemaSpec}.
|
* Infer the TS argument type for a complete {@link SchemaSpec}.
|
||||||
@@ -117,7 +117,7 @@ function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>;
|
|||||||
if (prop.enum) result.enum = prop.enum
|
if (prop.enum) result.enum = prop.enum
|
||||||
if (prop.default !== undefined) result.default = prop.default
|
if (prop.default !== undefined) result.default = prop.default
|
||||||
|
|
||||||
let required = prop.required === true
|
const required = prop.required === true
|
||||||
|
|
||||||
if (prop.type === 'object' && prop.properties) {
|
if (prop.type === 'object' && prop.properties) {
|
||||||
const nested = schemaSpecToJsonSchema(prop.properties)
|
const nested = schemaSpecToJsonSchema(prop.properties)
|
||||||
@@ -224,6 +224,9 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
|||||||
description: options.description,
|
description: options.description,
|
||||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||||
...options.strict !== undefined ? { strict: options.strict } : {},
|
...options.strict !== undefined ? { strict: options.strict } : {},
|
||||||
execute: options.execute as ToolDefinition['execute'],
|
// Object-literal execute methods don't use `this`; passing the reference
|
||||||
|
// through is safe.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
|
execute: options.execute,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const echoTool = defineTool({
|
|||||||
description: 'echo arguments back',
|
description: 'echo arguments back',
|
||||||
parameters: { text: { type: 'string' } },
|
parameters: { text: { type: 'string' } },
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
return [{ type: 'text' as const, text: String(args.text ?? '') }]
|
return [{ type: 'text' as const, text: args.text ?? '' }]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ describe('schema DSL regressions (Codex review round 2)', () => {
|
|||||||
...echoTool,
|
...echoTool,
|
||||||
name: 'object-thrower',
|
name: 'object-thrower',
|
||||||
async execute() {
|
async execute() {
|
||||||
// eslint-disable-next-line no-throw-literal — testing non-Error throws
|
// testing non-Error throws on purpose
|
||||||
throw { message: 'denied by object' }
|
throw { message: 'denied by object' }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user