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:
@@ -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)
|
||||
})
|
||||
process.stdout.write('echo-agent ready. Type a message ("echo <text>" triggers the tool).\n> ')
|
||||
return () => reader.close()
|
||||
return () => { reader.close() }
|
||||
}, 'stdio-chat')
|
||||
}
|
||||
@@ -14,14 +14,19 @@
|
||||
"scripts": {
|
||||
"build": "tsc -b tsconfig.build.json && tsx scripts/build.ts",
|
||||
"typecheck": "tsc -b tsconfig.build.json && tsc -p tsconfig.typecheck.json",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"demo": "node --expose-internals --import tsx examples/echo-agent/start.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/node": "^25.3.5",
|
||||
"dumble": "^0.2.3",
|
||||
"eslint": "^10.4.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export class LoopAgent implements Agent {
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
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)
|
||||
this.inbox.steer({ content, source })
|
||||
this.ctx.emit('agent/queued', this, content, { source, steering: true })
|
||||
@@ -88,7 +88,7 @@ export class LoopAgent implements Agent {
|
||||
*/
|
||||
start(): () => void {
|
||||
this.done = runLoop(this.ctx, this, {
|
||||
setStatus: status => this.setStatus(status),
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
setAbort: controller => void (this.currentAbort = controller),
|
||||
disposed: this.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.
|
||||
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) {
|
||||
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 {
|
||||
shouldContinue = await ctx.waterfall(
|
||||
'agent/turn-continuation', agent, turn, defaultDecision,
|
||||
async () => defaultDecision,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// A broken continuation plugin ends the turn, not the loop.
|
||||
@@ -246,7 +248,7 @@ async function runStep(
|
||||
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
||||
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) {
|
||||
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
|
||||
// tool dispatch actually uses.
|
||||
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 })
|
||||
if (assembler.usage) {
|
||||
@@ -297,6 +299,9 @@ async function runStep(
|
||||
content: result.content,
|
||||
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'))
|
||||
}
|
||||
|
||||
@@ -305,9 +310,6 @@ async function runStep(
|
||||
|
||||
/** The last turn number in a (possibly seeded) session log, or 0. */
|
||||
function lastTurnNumber(session: Session): number {
|
||||
for (let index = session.events.length - 1; index >= 0; index--) {
|
||||
const event = session.events[index]!
|
||||
if (event.type === 'turn/start') return event.data.turn
|
||||
}
|
||||
return 0
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
return lastStart?.data.turn ?? 0
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { StreamChunk, ToolResultBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEventType, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import LlmService, { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -99,7 +99,7 @@ describe('agent loop', () => {
|
||||
expect(toolResultMessage).toBeDefined()
|
||||
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
|
||||
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
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
@@ -380,7 +380,7 @@ describe('agent loop', () => {
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
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 () => {
|
||||
@@ -405,6 +405,6 @@ describe('agent loop', () => {
|
||||
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
||||
// event-by-event identity of types
|
||||
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[] {
|
||||
return [
|
||||
{ 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: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
@@ -60,8 +60,8 @@ export class MockAdapter extends LlmAdapter {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (options.signal?.aborted) return reject(new Error('aborted'))
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||
if (options.signal?.aborted) { reject(new Error('aborted')); return }
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class AgentRegistry extends Service {
|
||||
* when the calling fiber is disposed. Returns the disposer.
|
||||
*/
|
||||
register(agent: Agent): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (this.store.has(agent.id)) {
|
||||
throw new Error(`agent "${agent.id}" is already registered`)
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export class AgentRegistry extends Service {
|
||||
this.ctx.emit('agent/disposed', agent)
|
||||
}
|
||||
}, '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 {
|
||||
|
||||
@@ -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. */
|
||||
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[] {
|
||||
const ready: ContentBlock[] = []
|
||||
while (this.flushed < this.order.length) {
|
||||
const index = this.order[this.flushed]!
|
||||
const partial = this.partials.get(index)!
|
||||
const index = this.order[this.flushed]
|
||||
if (index === undefined) break
|
||||
const partial = this.mustGet(index)
|
||||
if (!partial.block) break
|
||||
ready.push(partial.block)
|
||||
this.flushed += 1
|
||||
@@ -141,8 +149,9 @@ export class BlockAssembler {
|
||||
flushRemaining(): ContentBlock[] {
|
||||
const remaining: ContentBlock[] = []
|
||||
while (this.flushed < this.order.length) {
|
||||
const index = this.order[this.flushed]!
|
||||
remaining.push(this.assemble(this.partials.get(index)!, index))
|
||||
const index = this.order[this.flushed]
|
||||
if (index === undefined) break
|
||||
remaining.push(this.assemble(this.mustGet(index), index))
|
||||
this.flushed += 1
|
||||
}
|
||||
return remaining
|
||||
|
||||
@@ -69,7 +69,7 @@ export class LlmService extends Service {
|
||||
* fiber.
|
||||
*/
|
||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
for (const model of models) {
|
||||
if (this.adapters.has(model)) {
|
||||
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')
|
||||
}
|
||||
}, '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. */
|
||||
|
||||
@@ -97,6 +97,10 @@ export class Session {
|
||||
deriveMessages(): Message[] {
|
||||
const messages: Message[] = []
|
||||
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) {
|
||||
case 'user/message': {
|
||||
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`)
|
||||
const session = new Session(id, seed)
|
||||
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.ctx.emit('session/created', session)
|
||||
return () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ export class SystemPrompt extends Service {
|
||||
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
|
||||
*/
|
||||
section(section: PromptSection): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.sections.push(section)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
return () => {
|
||||
@@ -82,6 +82,9 @@ export class SystemPrompt extends Service {
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
}, '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`.
|
||||
*/
|
||||
tools(provider: () => ToolSchema[]): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.toolProviders.push(provider)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
return () => {
|
||||
@@ -99,6 +102,9 @@ export class SystemPrompt extends Service {
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
}, '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),
|
||||
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)
|
||||
|
||||
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.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.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (this.store.has(definition.name)) {
|
||||
throw new Error(`tool "${definition.name}" is already registered`)
|
||||
}
|
||||
@@ -118,6 +118,9 @@ export class ToolRegistry extends Service {
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
}, '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 {
|
||||
@@ -130,6 +133,8 @@ export class ToolRegistry extends Service {
|
||||
* assembly.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,11 +60,11 @@ export type SchemaSpec = Record<string, SchemaProp>
|
||||
/** Map a {@link SchemaType} to its TS primitive type. */
|
||||
type TypeOf<T extends SchemaType> =
|
||||
T extends 'string' ? string :
|
||||
T extends 'number' ? number :
|
||||
T extends 'boolean' ? boolean :
|
||||
T extends 'object' ? Record<string, unknown> :
|
||||
T extends 'array' ? unknown[] :
|
||||
never
|
||||
T extends 'number' ? number :
|
||||
T extends 'boolean' ? boolean :
|
||||
T extends 'object' ? Record<string, unknown> :
|
||||
T extends 'array' ? unknown[] :
|
||||
never
|
||||
|
||||
/** Flatten an intersection into one object type for readable hovers. */
|
||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||
@@ -82,8 +82,8 @@ type RequiredKeys<S extends SchemaSpec> =
|
||||
*/
|
||||
type InferPropValue<P extends SchemaProp> =
|
||||
P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
|
||||
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
|
||||
TypeOf<P['type']>
|
||||
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
|
||||
TypeOf<P['type']>
|
||||
|
||||
/**
|
||||
* 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.default !== undefined) result.default = prop.default
|
||||
|
||||
let required = prop.required === true
|
||||
const required = prop.required === true
|
||||
|
||||
if (prop.type === 'object' && prop.properties) {
|
||||
const nested = schemaSpecToJsonSchema(prop.properties)
|
||||
@@ -224,6 +224,9 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...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',
|
||||
parameters: { text: { type: 'string' } },
|
||||
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,
|
||||
name: 'object-thrower',
|
||||
async execute() {
|
||||
// eslint-disable-next-line no-throw-literal — testing non-Error throws
|
||||
// testing non-Error throws on purpose
|
||||
throw { message: 'denied by object' }
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user