From 3d05fdfbfbd062d61b713147d418be6080572ac2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 11:32:38 +0800 Subject: [PATCH] refactor(session): keep instruction and skill scans on event log --- .../context/agent-instructions/package.json | 6 +- .../context/agent-instructions/src/index.ts | 7 - .../context/agent-instructions/src/render.ts | 2 +- .../context/agent-instructions/src/state.ts | 62 +------ .../tests/agent-instructions.spec.ts | 173 +++++------------- packages/skill/tool-skill/package.json | 9 +- packages/skill/tool-skill/src/index.ts | 63 ++----- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 - pnpm-lock.yaml | 9 - 9 files changed, 77 insertions(+), 258 deletions(-) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index e1d555cb0a..255ee9e720 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -39,12 +39,10 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 25b35a4169..ab68bce9d0 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -15,13 +15,11 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' import { applyInstructionVersionUpdates, baselineInstructionState, - createWorkspaceInstructionsProjection, name, reconcileInstructionContext, workspaceContextMessage, @@ -79,12 +77,7 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } -/** Required services (the projection registry drives the instruction fold). */ -export const inject = ['sessionProjections'] - export function apply(ctx: Context, config: Config): void { - ctx.sessionProjections.register(createWorkspaceInstructionsProjection()) - const resolved: ResolvedConfig = resolveConfig(config) const instructionVersions: InstructionVersionCache = new WeakMap() const baselinePreparations = new WeakMap - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Newest-first workspace-instruction change history by scope. */ - workspaceInstructions: WorkspaceInstructionsState - } -} - -/** - * Create the workspace-instruction projection. - * @returns Workspace-instruction projection definition. - */ -export function createWorkspaceInstructionsProjection(): ProjectionDefinition<'workspaceInstructions', WorkspaceInstructionsState> { - return { - key: 'workspaceInstructions', - stateSchema: workspaceInstructionsStateSchema, - init: () => ({}), - apply: (state, event) => { - if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) return state - const changes = workspaceInstructionChanges(event.data.source) - if (changes.length === 0) return state - let next = state - for (const change of changes) { - const record = { change, seq: event.seq } - const history = next[change.scope] - next = { ...next, [change.scope]: history === undefined ? [record] : [record, ...history] } - } - return next - }, - stateVersion: 2, - } -} - function visibleInstructionChanges( agent: Agent, authorityMessages: readonly UserMessage[], ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - const folded = agent.ctx.get('sessionProjections')?.stateOf(agent.session, 'workspaceInstructions') - if (folded === undefined) throw new Error('workspaceInstructions projection is not registered') - for (const [scope, history] of Object.entries(folded)) { - // History is newest-first; the latest visible record restores the previous - // scan-visible semantics when a surface replacement shadows the newest - // change but an older one stays visible. - const latestVisible = history.find(record => visibleSeqs.has(record.seq)) - if (latestVisible !== undefined) visible.set(scope, latestVisible.change) + for (const [seq, event] of agent.session.events.entries()) { + if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue + const changes = workspaceInstructionChanges(event.data.source) + for (const change of changes) { + if (visibleSeqs.has(seq)) visible.set(change.scope, change) + } } for (const message of authorityMessages) { if (!isWorkspaceContextSource(message.source)) continue diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 86052dc379..11d385106e 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -37,7 +37,6 @@ import { import { applyInstructionVersionUpdates, baselineInstructionState, - createWorkspaceInstructionsProjection, reconcileInstructionContext, type InstructionVersionCache, } from '../src/state.ts' @@ -169,16 +168,9 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } -async function pluginWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { - if (ctx.get('sessionProjections') === undefined) { - await ctx.plugin(SessionProjectionRegistry) - } - return ctx.plugin(workspaceContext, config) -} - async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) - return pluginWorkspaceContext(ctx, config) + return ctx.plugin(workspaceContext, config) } async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { @@ -186,17 +178,14 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - return pluginWorkspaceContext(ctx, config) + return ctx.plugin(workspaceContext, config) } function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { const id = SessionId('s1') const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) - const ctx = new Context() - new SessionProjectionRegistry(ctx) - ctx.sessionProjections.register(createWorkspaceInstructionsProjection()) return { - ctx, + ctx: new Context(), id: SessionId('a1'), options: {}, session, @@ -205,7 +194,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { send: () => {}, followup: () => {}, steer: () => {}, - inject: () => { throw new Error('workspace-context must append directly to the open step') }, + inject: () => { throw new Error('agent-instructions must append directly to the open step') }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), @@ -634,10 +623,10 @@ describe('workspace context instruction discovery', () => { it('labels the default DSH home as ~/.dsh when HOME points at the configured default', async () => { const root = await tempRepo() const home = await tempRepo() - vi.stubEnv('DSH_HOME', '') try { await write(join(home, '.dsh/AGENTS.md'), 'global default rule') + // A set DSH_HOME would override the homedir default and relabel the home. vi.stubEnv('DSH_HOME', '') vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) @@ -997,27 +986,26 @@ describe('workspace context request injection', () => { it('requires an explicit maxBytes configuration', async () => { const ctx = new Context() - await expect(pluginWorkspaceContext(ctx, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) + await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { const ctx = new Context() try { - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) } finally { await ctx.fiber.dispose() } }) it('does not declare fs as a static inject dependency', () => { - expect(workspaceContext.inject).toEqual(['sessionProjections']) - expect(workspaceContext.inject).not.toContain('fs') + expect('inject' in workspaceContext).toBe(false) }) it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() try { - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -1124,7 +1112,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) await composeBaselinePrefix(ctx, original) @@ -1368,7 +1356,7 @@ describe('workspace context request injection', () => { expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true }) await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1414,7 +1402,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) @@ -1467,7 +1455,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) - await pluginWorkspaceContext(resumedCtx, { dshHome: home, maxBytes }) + await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1577,7 +1565,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(10_000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1662,7 +1650,7 @@ describe('workspace context request injection', () => { // Hot remount over the live session: the durable baseline remains // visible, so the fresh mount does not append a duplicate. await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(1) @@ -1692,7 +1680,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - const fiber = await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] @@ -1707,7 +1695,7 @@ describe('workspace context request injection', () => { }) await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(2) @@ -1994,7 +1982,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2017,7 +2005,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2059,7 +2047,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2084,7 +2072,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(instructionPath, { type: 'file', content: 'far too large' }) fs.omitSizes.add(instructionPath) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2107,7 +2095,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as BlockingReadFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'blocked' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( @@ -2141,7 +2129,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' }) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2167,7 +2155,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2190,7 +2178,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2213,7 +2201,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2235,7 +2223,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2260,7 +2248,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.throwOnStat.add(join(root, '.git')) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2310,7 +2298,7 @@ describe('workspace context request injection', () => { await write(join(cwd, 'AGENTS.md'), 'child schema default rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -2331,7 +2319,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.local.md'), 'local rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2527,7 +2515,8 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) @@ -2603,7 +2592,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel dynamic reconciliation') controller.abort(reason) @@ -2866,7 +2855,7 @@ describe('dynamic nested workspace context injection', () => { fs.omitSizes.add(instructionPath) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -2903,7 +2892,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-1') }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await ctx.tools.execute({ @@ -2948,7 +2937,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'shared path, separate sessions' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const firstAgent = stubAgent(root) const secondAgent = stubAgent(root) @@ -3484,7 +3473,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -3626,72 +3615,6 @@ describe('dynamic nested workspace context injection', () => { } }) - it('still removes a deleted instruction whose latest update is shadowed while an older one stays visible', async () => { - const root = await tempRepo() - const home = await tempRepo() - try { - await mkdir(join(root, '.git'), { recursive: true }) - await write(join(root, 'pkg/AGENTS.md'), 'first nested rule') - await write(join(root, 'pkg/deep/file.txt'), 'hello') - const ctx = new Context() - await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) - - const first = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-before-update'), - name: 'read', - arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent, - }) - const firstSeq = (await appendAdditionalContexts(ctx, agent))! - - await write(join(root, 'pkg/AGENTS.md'), 'second nested rule') - const second = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-after-update'), - name: 'read', - arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent, - }) - const secondSeq = (await appendAdditionalContexts(ctx, agent))! - expect(agent.session.surface.nodes).toContain(firstSeq) - expect(agent.session.surface.nodes).toContain(secondSeq) - - // A surface replacement shadows only the latest update; the older one - // stays visible, so the scope still has visible state to lose. - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'compacted summary' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), { - surfaceOp: { op: 'replace', start: secondSeq, end: secondSeq }, - sourceEventSeqs: [secondSeq], - }) - - await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('read-after-delete'), - name: 'read', - arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent, - }) - await appendAdditionalContexts(ctx, agent) - - expect(first.additionalContexts).toBeUndefined() - expect(second.additionalContexts).toBeUndefined() - const removal = agent.session.events.find(event => event.type === 'user/message' - && event.data.source.kind === 'agent-instructions' - && event.data.source.changes.some(change => change.action === 'remove')) - expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({ - changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], - }) - } finally { - await rm(root, { recursive: true, force: true }) - await rm(home, { recursive: true, force: true }) - } - }) - it('re-arms an unchanged baseline after compaction removes it from the surface', async () => { const root = await tempRepo() const home = await tempRepo() @@ -3929,7 +3852,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' }) fs.throwOnRead.add(nested) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = await ctx.tools.execute({ @@ -4070,7 +3993,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] } : downstream }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4139,7 +4062,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] } : downstream }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4163,7 +4086,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' }) @@ -4238,7 +4161,7 @@ describe('dynamic nested workspace context injection', () => { agent.session.append('step/start', { turn: 1, step: 1 }) agent.session.append('step/end', { turn: 1, step: 1 }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4261,7 +4184,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const agent = stubAgent('/') const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } @@ -4310,7 +4233,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const root = resolve('/') const agent = stubAgent(root) @@ -4376,7 +4299,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 20 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -4498,7 +4421,7 @@ describe('workspace context inbox synchronization', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 1 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 }) const agent = stubAgent(root) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4574,7 +4497,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, @@ -4614,7 +4537,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 36ef65599f..798c09ca05 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -37,12 +37,10 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,7 +51,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 72af2f7006..222e8a0ace 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -7,12 +7,10 @@ import { createHash } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { z as zod } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' import { escapeText, isModelInvocable, @@ -24,7 +22,7 @@ import { } from '@deepseek-ai/dsh-skill' export const name = 'tool-skill' -export const inject = ['agents', 'tools', 'skills', 'sessionProjections'] +export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 /** @@ -70,30 +68,12 @@ export const Config: z = z.object({ catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH), }) -/** - * The skill-catalog projection's state schema — the one definition of the - * state shape; the type is inferred from it (state equals the public shape). - */ -const skillCatalogStateSchema = zod.array(zod.object({ - digest: zod.string().min(1), - seq: zod.number().int().nonnegative(), -})).nullable() - -type SkillCatalogState = zod.infer - /** * Register the model-facing skill loader and its visibility-matched * durable session catalog. The catalog is emitted only when the calling agent * resolves this plugin's exact tool registration; a restriction or scoped * same-name shadow therefore removes both the schema and its call guidance. */ -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Newest-first published skill-catalog messages (digest + seq); null before the first publication. */ - skillCatalog: SkillCatalogState - } -} - export function apply(ctx: Context, config: Config = {}): void { const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3) @@ -223,20 +203,6 @@ export function apply(ctx: Context, config: Config = {}): void { return { ...decision, messages: [...decision.messages, ...injections] } }) - ctx.sessionProjections.register({ - key: 'skillCatalog', - stateVersion: 2, - stateSchema: skillCatalogStateSchema, - init: () => null, - apply: (state, event) => { - if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') return state - const entries = readCatalogEntries(event.data.source) - if (entries === undefined) return state - const record = { digest: digestCatalogEntries(entries), seq: event.seq } - return state === null ? [record] : [record, ...state] - }, - }) - // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. // @@ -260,7 +226,7 @@ export function apply(ctx: Context, config: Config = {}): void { const skills = snapshot.skills.filter(isModelInvocable) const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength) const digest = digestCatalogEntries(entries) - const history = catalogHistory(ctx, agent) + const history = catalogHistory(agent) const existing = catalogMessage(decision.messages) if (history.visibleDigest === digest) { return existing === undefined @@ -392,17 +358,22 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un return readable } -function catalogHistory(ctx: Context, agent: Agent): { visibleDigest?: string; published: boolean } { +function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) - const state = ctx.sessionProjections.stateOf(agent.session, 'skillCatalog') ?? null - if (state === null) return { published: false } - // History is newest-first; the latest visible record restores the previous - // scan-visible semantics when a surface replacement shadows the newest - // catalog message but an older one stays visible. - const latestVisible = state.find(record => visible.has(record.seq)) - return latestVisible !== undefined - ? { visibleDigest: latestVisible.digest, published: true } - : { published: true } + const events = agent.session.events + let published = false + for (let index = events.length - 1; index >= 0; index -= 1) { + // The loop bounds prove the read-only event view contains this index. + // oxlint-disable-next-line typescript/no-non-null-assertion + const event = events[index]! + if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue + const entries = readCatalogEntries(event.data.source) + if (entries === undefined) continue + const digest = digestCatalogEntries(entries) + published = true + if (visible.has(event.seq)) return { visibleDigest: digest, published } + } + return { published } } function catalogMessage( diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 4b63ba8207..d8dd9df2f4 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -9,7 +9,6 @@ import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepse import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -31,7 +30,6 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) @@ -754,7 +751,6 @@ describe('dsh-tool-skill', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 368df9b58d..18a33e8793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3879,9 +3879,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -8039,9 +8036,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -8061,9 +8055,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session/session-projection '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill