Merge remote-tracking branch 'origin/master' into worktree/session-reference
# Conflicts: # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # packages/compact/compact-basic/src/region.ts # packages/compact/compact/README.md # packages/compact/compact/tests/compact.spec.ts # packages/examples/acp-demo/package.json # packages/ui/tui/README.md # packages/ui/tui/package.json # packages/ui/tui/src/index.ts # packages/ui/tui/tests/harness.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -27,6 +32,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -36,6 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
30
packages/context/session-reference/src/invariant.ts
Normal file
30
packages/context/session-reference/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-reference`.
|
||||
* @module @deepseek-ai/dsh-session-reference/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-reference'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-reference-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: preparation returns immutable per-call snapshots validated while they are
|
||||
* built, and the agent/session layers own durable context admission, freezing, and replay.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -14,6 +14,7 @@
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../compact/compact" },
|
||||
{ "path": "../../support/invariants" },
|
||||
{ "path": "../../session-query/session-query" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ Step 1 measures from the latest preceding model-visible message, including the p
|
||||
|
||||
A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback.
|
||||
|
||||
The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -26,12 +31,15 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
114
packages/context/time-context/src/invariant.ts
Normal file
114
packages/context/time-context/src/invariant.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/** Package-owned durable clock-context invariants. @module @deepseek-ai/dsh-time-context/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-time-context'
|
||||
const SOURCE_NAME = 'time-context'
|
||||
const READING = new RegExp(
|
||||
'^Time sampled while preparing turn (\\d+), step (\\d+): '
|
||||
+ '(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n'
|
||||
+ 'Elapsed since the preceding (model-visible message|step context): '
|
||||
+ '(?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$',
|
||||
)
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'time-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Derive the pre-step position at which a time-context reading may append. */
|
||||
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
|
||||
const currentTurnEvents: SessionEvent[] = []
|
||||
let openTurn: number | undefined
|
||||
for (const event of history.slice().reverse()) {
|
||||
if (event.type === 'turn/end') {
|
||||
fail('time-context reading must be appended inside an open turn')
|
||||
}
|
||||
if (event.type === 'turn/start') {
|
||||
openTurn = event.data.turn
|
||||
break
|
||||
}
|
||||
currentTurnEvents.push(event)
|
||||
}
|
||||
if (openTurn === undefined) fail('time-context reading must be appended inside an open turn')
|
||||
|
||||
for (const event of currentTurnEvents) {
|
||||
if (event.type === 'step/start') {
|
||||
fail(`time-context reading must precede step/start, but step ${event.data.step} is already open`)
|
||||
}
|
||||
if (event.type === 'step/end') {
|
||||
return { turn: openTurn, step: event.data.step + 1 }
|
||||
}
|
||||
}
|
||||
return { turn: openTurn, step: 1 }
|
||||
}
|
||||
|
||||
/** Validate one plugin-attributed time reading against its session position and timestamp. */
|
||||
function validateReading(
|
||||
history: readonly SessionEvent[],
|
||||
event: SessionEvent<'context/message'>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
const [block] = event.data.content
|
||||
if (event.data.content.length !== 1 || block?.type !== 'text') {
|
||||
fail('time-context messages must contain exactly one text block')
|
||||
}
|
||||
const match = READING.exec(block.text)
|
||||
if (match === null) fail('time-context message does not match the durable reading format')
|
||||
const turn = Number(match[1])
|
||||
const step = Number(match[2])
|
||||
if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) {
|
||||
fail('time-context turn and step must be positive safe integers')
|
||||
}
|
||||
const expected = preparationPosition(history, fail)
|
||||
if (turn !== expected.turn || step !== expected.step) {
|
||||
fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`)
|
||||
}
|
||||
const baseline = match[4]
|
||||
if ((step === 1) !== (baseline === 'model-visible message')) {
|
||||
fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`)
|
||||
}
|
||||
const rendered = match[3]
|
||||
/* v8 ignore next -- the preceding fixed regexp always supplies capture group three. */
|
||||
if (rendered === undefined) fail('time-context reading omitted its rendered timestamp')
|
||||
const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, ''))
|
||||
if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time)
|
||||
|| event.time < renderedTime) {
|
||||
fail('time-context rendered timestamp must parse and not postdate its durable event')
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Validate all package-owned readings already present in one session. */
|
||||
function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
for (const [index, event] of session.events.entries()) {
|
||||
if (event.type !== 'context/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) continue
|
||||
validateReading(session.events.slice(0, index), event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation for loaded and newly appended context readings. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) validateSession(session, fail)
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'context/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) return
|
||||
validateReading(session.events, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the time-context invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
177
packages/context/time-context/tests/invariant.spec.ts
Normal file
177
packages/context/time-context/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const SECOND = Date.parse('2026-07-14T00:00:00Z')
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(TimeInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent {
|
||||
return {
|
||||
type: 'context/message',
|
||||
seq: 0,
|
||||
time,
|
||||
data: {
|
||||
content: (content ?? [{ type: 'text', text }]) as ContentBlock[],
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function reading(
|
||||
turn = '1',
|
||||
step = '1',
|
||||
baseline = 'model-visible message',
|
||||
timestamp = '2026-07-14T00:00:00+00:00[UTC]',
|
||||
): string {
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: unavailable.`
|
||||
}
|
||||
|
||||
function preparing(turn: number, step: number): Session {
|
||||
const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
|
||||
for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
|
||||
session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
for (let priorStep = 1; priorStep < step; priorStep += 1) {
|
||||
session.append('step/start', { turn, step: priorStep })
|
||||
session.append('step/end', { turn, step: priorStep })
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function appendReading(session: Session, text: string): void {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('time-context invariants', () => {
|
||||
it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => {
|
||||
const ctx = await setup()
|
||||
const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n'
|
||||
+ 'Elapsed since the preceding step context: 4m 2s.'
|
||||
expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts a reading durably appended after a long process pause', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), event(reading(), SECOND + 60_000))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('validates each existing reading against its preceding durable prefix', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('time-invariant-late-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'prepare' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendReading(session, reading())
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects an invalid existing reading on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('time-invariant-late-invalid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'prepare' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendReading(session, reading('1', '2', 'step context'))
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[reading('1', '3', 'step context'), /expected turn 2\/step 3/],
|
||||
[reading('2', '2', 'step context'), /expected turn 2\/step 3/],
|
||||
])('rejects a reading that disagrees with its session position', async (text, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects a reading after cancellation closes the turn', async () => {
|
||||
const ctx = await setup()
|
||||
const session = preparing(1, 2)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } })
|
||||
expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
|
||||
.toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('rejects a reading after step/start or without any open turn', async () => {
|
||||
const ctx = await setup()
|
||||
const started = preparing(1, 1)
|
||||
started.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/)
|
||||
expect(() => {
|
||||
ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
|
||||
}).toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['not a reading', SECOND, undefined, /durable reading format/],
|
||||
[reading('0'), SECOND, undefined, /positive safe integers/],
|
||||
[reading('999999999999999999999'), SECOND, undefined, /positive safe integers/],
|
||||
[reading('1', '0', 'step context'), SECOND, undefined, /positive safe integers/],
|
||||
[reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/],
|
||||
[reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/],
|
||||
[reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/],
|
||||
[reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /must parse and not postdate/],
|
||||
[reading(), Number.NaN, undefined, /must parse and not postdate/],
|
||||
[reading(), SECOND - 1, undefined, /must parse and not postdate/],
|
||||
['ignored', SECOND, [], /exactly one text block/],
|
||||
['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/],
|
||||
['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/],
|
||||
] as const)('rejects an incoherent durable reading', async (text, time, content, message) => {
|
||||
const ctx = await setup()
|
||||
const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, preparationStep), event(
|
||||
text,
|
||||
time,
|
||||
content === undefined ? undefined : [...content],
|
||||
))
|
||||
}).toThrow(message)
|
||||
})
|
||||
|
||||
it('ignores context messages owned by another package', async () => {
|
||||
const ctx = await setup()
|
||||
const other = event('unrelated') as SessionEvent<'context/message'>
|
||||
other.data.source = { kind: 'plugin', plugin: 'other' }
|
||||
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
|
||||
other.data.source = { kind: 'user' }
|
||||
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), {
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
})
|
||||
ctx.emit('tools/change')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
@@ -83,7 +82,7 @@ async function fire(
|
||||
step: number,
|
||||
signal: AbortSignal = SIGNAL,
|
||||
): Promise<void> {
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, signal)
|
||||
await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal)
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
|
||||
@@ -6,13 +6,35 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../support/loader-smoke" }
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/loader-smoke"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -39,6 +45,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
|
||||
30
packages/context/workspace-context/src/invariant.ts
Normal file
30
packages/context/workspace-context/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-workspace-context`.
|
||||
* @module @deepseek-ai/dsh-workspace-context/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'workspace-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata,
|
||||
* while focused pipeline tests own its private pending/cache state transitions.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -7,8 +7,9 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
@@ -23,7 +24,12 @@ import type {
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
PostToolDecision,
|
||||
ToolExecution,
|
||||
ToolExecutionResult,
|
||||
ToolExecutionToken,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import {
|
||||
discoverBaselineInstructionFiles,
|
||||
@@ -225,14 +231,31 @@ const composedPrefixes = new WeakMap<object, Message[]>()
|
||||
|
||||
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
const prefix = await ctx.waterfall(
|
||||
'agent/session-prefix', agent, empty, AbortSignal.timeout(1000),
|
||||
const prefix = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, AbortSignal.timeout(1000),
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
composedPrefixes.set(agent, prefix)
|
||||
return prefix
|
||||
}
|
||||
|
||||
function toolEventCarrier(ctx: Context, exec: ToolExecution) {
|
||||
return scopeTarget(ctx.get('tools') ?? ctx as unknown as ToolRegistry, exec.agent)
|
||||
}
|
||||
|
||||
function postExecute(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
result: Readonly<ToolExecutionResult>,
|
||||
next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
return ctx.waterfall(toolEventCarrier(ctx, exec), 'tools/post-execute', exec, result, next)
|
||||
}
|
||||
|
||||
function emitToolResult(ctx: Context, exec: ToolExecution, result: Readonly<ToolExecutionResult>): void {
|
||||
ctx.emit(toolEventCarrier(ctx, exec), 'tools/result', exec, result)
|
||||
}
|
||||
|
||||
function derivedText(agent: Agent): string {
|
||||
return blocksText(composedPrefixes.get(agent)?.[0]?.content)
|
||||
}
|
||||
@@ -795,7 +818,7 @@ describe('workspace context request injection', () => {
|
||||
try {
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
|
||||
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const decision = await postExecute(ctx, stubToolExecution({
|
||||
callId: CallId('no-fs-post-execute'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/file.txt' },
|
||||
@@ -842,7 +865,7 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
|
||||
// A later PostToolUse-style policy blocks this otherwise-successful read.
|
||||
const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({
|
||||
const blocked = await postExecute(ctx, exec, result, async () => ({
|
||||
kind: 'block' as const,
|
||||
feedback: [{ type: 'text' as const, text: 'blocked by policy' }],
|
||||
}))
|
||||
@@ -856,7 +879,7 @@ describe('workspace context request injection', () => {
|
||||
// The same read, when the downstream accepts, DOES surface the nested
|
||||
// instructions — proving the block branch above is what suppressed them,
|
||||
// and that the block did not consume the pending nested change.
|
||||
const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({
|
||||
const accepted = await postExecute(ctx, exec, result, async () => ({
|
||||
kind: 'accept' as const,
|
||||
}))
|
||||
expect(accepted.kind).toBe('accept')
|
||||
@@ -1168,8 +1191,9 @@ describe('workspace context request injection', () => {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel prefix')
|
||||
const empty: Message[] = []
|
||||
const pending = ctx.waterfall(
|
||||
'agent/session-prefix', stubAgent(root), empty, controller.signal,
|
||||
const agent = stubAgent(root)
|
||||
const pending = agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, controller.signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
|
||||
@@ -1659,7 +1683,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const pending = ctx.waterfall('tools/post-execute', exec, {
|
||||
const pending = postExecute(ctx, exec, {
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}, () => Promise.resolve({ kind: 'accept' as const }))
|
||||
@@ -2385,12 +2409,12 @@ describe('dynamic nested workspace context injection', () => {
|
||||
isError: false,
|
||||
}
|
||||
|
||||
const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const failedStat = await postExecute(ctx, stubToolExecution({
|
||||
callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
}), result, async () => ({ kind: 'accept' as const }))
|
||||
fs.throwOnStat.clear()
|
||||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' })
|
||||
const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const mismatchedStat = await postExecute(ctx, stubToolExecution({
|
||||
callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
}), result, async () => ({ kind: 'accept' as const }))
|
||||
|
||||
@@ -2624,19 +2648,19 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const parent = Symbol('parent') as ToolExecutionToken
|
||||
const plainResult = { callId: CallId('plain'), content: [], isError: false }
|
||||
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
|
||||
}), plainResult)
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] })
|
||||
ctx.emit('tools/result', {
|
||||
emitToolResult(ctx, {
|
||||
...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
|
||||
token: parent,
|
||||
}, plainResult)
|
||||
@@ -2672,7 +2696,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
const decision = await postExecute(ctx, stubToolExecution({
|
||||
callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`),
|
||||
name: item.name,
|
||||
arguments: item.arguments,
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user