mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'worktree/agent-execution-context-rfc' into worktree/explicit-turn-signal
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
# context/ — optional request context
|
||||
# context/ — request-context extensions
|
||||
|
||||
Opt-in plugins that add bounded model-visible request context without defining a tool or service. The default `dsh-agent-spine-demo` bundle excludes them.
|
||||
Product plugins that add model-visible request context without defining a tool or service. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `time-context/` | Current time and elapsed-time system-prompt context | (none) |
|
||||
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
|
||||
|
||||
The [`workspace-context` decision record](../../docs/rfc/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-time-context
|
||||
|
||||
Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md).
|
||||
Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -8,36 +8,51 @@ Opt-in dynamic system-prompt context with the current zoned time and elapsed tim
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
config:
|
||||
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
|
||||
refreshIntervalMs: 60000 # default; 0 refreshes on every step
|
||||
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
|
||||
```
|
||||
|
||||
When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. `refreshIntervalMs` must be a non-negative safe integer. Every turn's first request refreshes; later steps reuse the reading until its age reaches the interval. `0` refreshes every step. Refresh occurs only during request assembly and creates no timer work.
|
||||
When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load.
|
||||
|
||||
## Message baseline
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` appends on every pre-step attempt whose signal is not already aborted. A positive value appends only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
|
||||
|
||||
The duration starts at the latest user, assistant, tool-result, context, or steering message before the current `turn/start`. Every refresh in the turn retains that baseline, so the current prompt does not collapse the interval to approximately zero. The first turn reports that no earlier message exists. The durable clock source is session-event append time, not client send time.
|
||||
## Timing semantics
|
||||
|
||||
The loop records the dynamic section in `request/header` / `request/header-delta`. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history.
|
||||
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
|
||||
|
||||
Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
|
||||
|
||||
Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`.
|
||||
|
||||
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 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
|
||||
|
||||
### Temporal system prompt
|
||||
### Preparation-time temporal context
|
||||
|
||||
**What the model sees**: Every request in an active turn includes the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; `<duration-or-unavailable>` is compact whole-second units or the first-turn fallback.
|
||||
**What the model sees**: On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
|
||||
|
||||
**Token effect**: Fixed two-line cost per request. A refresh replaces the request-header section; prior readings do not accumulate.
|
||||
**Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
|
||||
|
||||
#### Temporal context section
|
||||
#### First step
|
||||
|
||||
```markdown
|
||||
Current time: <timestamp>
|
||||
Time since previous message: <duration-or-unavailable>.
|
||||
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
#### Later steps
|
||||
|
||||
```markdown
|
||||
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
||||
Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Request-bound refresh only** — no clock update is emitted while the agent is waiting inside a model call or tool; the next assembled step refreshes once the configured interval has elapsed.
|
||||
- **Whole-second display** — timestamps and durations omit sub-second precision even when `refreshIntervalMs` is below 1,000.
|
||||
- **Session-event baseline** — elapsed time starts from the durable append timestamp, not a client transport's original send timestamp.
|
||||
- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds.
|
||||
- **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp.
|
||||
- **Process-local default zone** — omission uses the Node process's `TZ`, host, or container zone captured at plugin load, not a remote user's zone; configure an explicit IANA zone when those differ.
|
||||
- **History cost between compactions** — omission or `0` retains one reading for every eligible preparation attempt, including attempts later cancelled or failed; a positive interval reduces but does not eliminate this cost.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-time-context",
|
||||
"description": "Opt-in system-prompt context with the current time and elapsed time since the previous message",
|
||||
"description": "Opt-in durable per-step context with the current time and elapsed time",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -26,13 +26,12 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-execution": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Opt-in request-time clock context. Active turns receive the current zoned
|
||||
* time and elapsed time since the preceding model-visible message. The loop
|
||||
* logs each rendered value as request-header state rather than conversation
|
||||
* history.
|
||||
* Opt-in request-preparation clock context. Eligible pre-step attempts append
|
||||
* durable, source-attributed time readings to conversation history.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-time-context
|
||||
*/
|
||||
@@ -10,77 +8,30 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'time-context'
|
||||
|
||||
/** The system-prompt registry that owns the dynamic request section. */
|
||||
export const inject = ['systemPrompt']
|
||||
/** The agent registry that owns the pre-step lifecycle seam. */
|
||||
export const inject = ['agents']
|
||||
|
||||
/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */
|
||||
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
||||
export interface Config {
|
||||
/** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
|
||||
timeZone?: string
|
||||
/** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */
|
||||
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
|
||||
refreshIntervalMs?: number
|
||||
}
|
||||
|
||||
/** Schemastery validation and defaults for {@link Config}. */
|
||||
/** Schemastery validation for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
timeZone: z.string(),
|
||||
refreshIntervalMs: z.number().default(60_000),
|
||||
refreshIntervalMs: z.number(),
|
||||
})
|
||||
|
||||
interface OpenTurn {
|
||||
turn: number
|
||||
startSeq: number
|
||||
}
|
||||
|
||||
/** Cached text and the fixed inter-turn baseline used by one agent's open turn. */
|
||||
interface RenderState {
|
||||
turn: number
|
||||
renderedAt: number
|
||||
previousMessageTime: number | undefined
|
||||
text: string
|
||||
}
|
||||
|
||||
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
|
||||
|
||||
function openTurn(agent: Agent): OpenTurn | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
switch (event.type) {
|
||||
case 'turn/end':
|
||||
return undefined
|
||||
case 'turn/start':
|
||||
return { turn: event.data.turn, startSeq: event.seq }
|
||||
default:
|
||||
// Merge-extensible session events: only turn boundaries matter here.
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find the latest model-visible timestamp strictly before one turn boundary. */
|
||||
function previousMessageTime(agent: Agent, turnStartSeq: number): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.seq >= turnStartSeq) continue
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
return event.time
|
||||
default:
|
||||
// Merge-extensible session events: non-surface records are not messages.
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
|
||||
function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
|
||||
const parts = Object.fromEntries(
|
||||
@@ -107,31 +58,85 @@ function formatDuration(elapsedMs: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** Find the latest model-visible event, excluding this plugin's pending append. */
|
||||
function precedingMessageTime(agent: Agent): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
return event.time
|
||||
default:
|
||||
// Merge-extensible session events: non-surface records are not messages.
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find the preceding time-context event within the open turn. */
|
||||
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
|
||||
if (event.type === 'context/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === name) {
|
||||
return event.time
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Find this plugin's latest durable injection, including a shadowed surface event. */
|
||||
function latestInjectionTime(agent: Agent): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'context/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === name) {
|
||||
return event.time
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function renderText(
|
||||
now: number,
|
||||
turn: number,
|
||||
step: number,
|
||||
previous: number | undefined,
|
||||
formatter: Intl.DateTimeFormat,
|
||||
timeZone: string,
|
||||
): string {
|
||||
const elapsed = previous === undefined
|
||||
? 'unavailable (no earlier message in this session)'
|
||||
: formatDuration(now - previous)
|
||||
return `Current time: ${formatTimestamp(now, formatter, timeZone)}\nTime since previous message: ${elapsed}.`
|
||||
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
|
||||
const baseline = step === 1 ? 'model-visible message' : 'step context'
|
||||
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
|
||||
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
|
||||
}
|
||||
|
||||
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
|
||||
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
|
||||
if (refreshIntervalMs !== undefined && (
|
||||
!Number.isSafeInteger(refreshIntervalMs)
|
||||
|| refreshIntervalMs < 0
|
||||
)) {
|
||||
throw new TypeError(
|
||||
`time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the request-time clock section for the lifetime of `ctx`.
|
||||
* @param ctx - plugin context; the section registration is disposed with it.
|
||||
* @param config - validated time zone and intra-turn refresh interval.
|
||||
* @throws when the time zone or refresh interval is invalid.
|
||||
* Register a prepended pre-step listener for the lifetime of `ctx`.
|
||||
* @param ctx - plugin context; the listener is disposed with it.
|
||||
* @param config - time zone and durable refresh scheduling configuration.
|
||||
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const timeZone = config.timeZone
|
||||
const refreshIntervalMs = config.refreshIntervalMs as number
|
||||
if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) {
|
||||
throw new Error(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${refreshIntervalMs}`)
|
||||
}
|
||||
|
||||
const refreshIntervalMs = config.refreshIntervalMs
|
||||
validateRefreshInterval(refreshIntervalMs)
|
||||
let formatter: Intl.DateTimeFormat
|
||||
try {
|
||||
formatter = new Intl.DateTimeFormat('en-US', {
|
||||
@@ -152,32 +157,29 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new Error(message, { cause: error })
|
||||
}
|
||||
const resolvedTimeZone = formatter.resolvedOptions().timeZone
|
||||
const states = new WeakMap<Agent, RenderState>()
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'context:time',
|
||||
order: 10,
|
||||
text(context: AssembleContext): string {
|
||||
const agent = context.agent
|
||||
if (agent === undefined) return ''
|
||||
const currentTurn = openTurn(agent)
|
||||
if (currentTurn === undefined) return ''
|
||||
|
||||
const now = Date.now()
|
||||
const prior = states.get(agent)
|
||||
if (prior !== undefined
|
||||
&& prior.turn === currentTurn.turn
|
||||
&& now >= prior.renderedAt
|
||||
&& now - prior.renderedAt < refreshIntervalMs) {
|
||||
return prior.text
|
||||
}
|
||||
|
||||
const previous = prior?.turn === currentTurn.turn
|
||||
? prior.previousMessageTime
|
||||
: previousMessageTime(agent, currentTurn.startSeq)
|
||||
const text = renderText(now, previous, formatter, resolvedTimeZone)
|
||||
states.set(agent, { turn: currentTurn.turn, renderedAt: now, previousMessageTime: previous, text })
|
||||
return text
|
||||
},
|
||||
})
|
||||
ctx.on('agent/pre-step', (
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
_fullSystemPrompt: string,
|
||||
_sessionPrefix: readonly Message[],
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (signal.aborted) return
|
||||
const now = Date.now()
|
||||
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
|
||||
const lastInjection = latestInjectionTime(agent)
|
||||
if (lastInjection !== undefined
|
||||
&& now >= lastInjection
|
||||
&& now - lastInjection < refreshIntervalMs) return
|
||||
}
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
agent.inject(
|
||||
[{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
|
||||
{ source: { kind: 'plugin', plugin: name } },
|
||||
)
|
||||
}, { prepend: true })
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
provider: mock
|
||||
model: mock-echo
|
||||
persona: 'Test the time-context plugin.'
|
||||
welcome: 'time-context e2e ready.'
|
||||
persistenceRoot: './.sessions'
|
||||
workspaceContext: false
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url))
|
||||
@@ -12,7 +12,8 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const PROCESS_TIMEOUT_MS = 30_000
|
||||
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
|
||||
const FIRST_REPLY = 'You said: "first". Try "echo <something>" to see a tool call.'
|
||||
const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:'
|
||||
const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:'
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | undefined
|
||||
let workdir: string | undefined
|
||||
@@ -60,7 +61,7 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!sentSecond && stdout.includes(`${FIRST_REPLY}\n> `)) {
|
||||
if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo <something>" to see a tool call.\n> ')) {
|
||||
sentSecond = true
|
||||
proc.stdin.end('second\n')
|
||||
}
|
||||
@@ -84,12 +85,12 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
|
||||
}
|
||||
|
||||
describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
it('uses the process zone and persists both first-turn and elapsed-time request context', async () => {
|
||||
it('uses the process zone and persists one ordered context event per request', async () => {
|
||||
const { stdout, stderr } = await runTwoTurns()
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stdout).toContain('time-context e2e ready.')
|
||||
expect(stdout).toContain(FIRST_REPLY)
|
||||
expect(stdout).toContain('You said: "second".')
|
||||
expect(stdout).toContain(SECOND_REPLY)
|
||||
|
||||
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
@@ -97,19 +98,28 @@ describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
|
||||
|
||||
const firstHeader = events.find(event => event.type === 'request/header')
|
||||
if (firstHeader?.type !== 'request/header') throw new Error('missing initial request/header event')
|
||||
expect(firstHeader.data.header.system).toMatch(
|
||||
/Current time: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
|
||||
const contexts = events.filter(event => event.type === 'context/message')
|
||||
const starts = events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(starts).toHaveLength(2)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
|
||||
expect(contexts[index]!.surfaceOp).toBe('append')
|
||||
expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
|
||||
}
|
||||
const contextText = contexts.map(event => event.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n'))
|
||||
expect(contextText[0]).toMatch(
|
||||
/Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
|
||||
)
|
||||
expect(firstHeader.data.header.system).toContain(
|
||||
'Time since previous message: unavailable (no earlier message in this session).',
|
||||
expect(contextText[0]).toMatch(
|
||||
/Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
|
||||
)
|
||||
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
|
||||
|
||||
const finalSystem = foldRequestHeader(events)?.system
|
||||
expect(finalSystem).toContain('[Asia/Shanghai]')
|
||||
expect(finalSystem).toMatch(
|
||||
/Time since previous message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
|
||||
)
|
||||
const headers = events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
}, TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as timeContext from '@deepseek-ai/dsh-time-context'
|
||||
import type { Config } from '@deepseek-ai/dsh-time-context'
|
||||
|
||||
const BASE = Date.parse('2026-07-14T00:00:00.000Z')
|
||||
const ORIGINAL_TIME_ZONE = process.env['TZ']
|
||||
const SIGNAL = new AbortController().signal
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['TZ'] = 'UTC'
|
||||
@@ -31,18 +31,29 @@ afterEach(() => {
|
||||
|
||||
async function mount(config: Config = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const fiber = await ctx.plugin(timeContext, config)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
return { id: AgentId(id), session } as unknown as Agent
|
||||
}
|
||||
|
||||
async function sectionText(ctx: Context, agent?: Agent): Promise<string | undefined> {
|
||||
const assembly = await ctx.systemPrompt.assemble(agent === undefined ? {} : { agent })
|
||||
return assembly.sections.find(section => section.name === 'context:time')?.text
|
||||
return {
|
||||
id: AgentId(id),
|
||||
options: {},
|
||||
session,
|
||||
status: 'running',
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
inject(content, options) {
|
||||
session.append('context/message', {
|
||||
content,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function openMessageTurn(session: Session, turn: number): void {
|
||||
@@ -53,6 +64,28 @@ function openMessageTurn(session: Session, turn: number): void {
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function contextTexts(session: Session): string[] {
|
||||
const texts: string[] = []
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'context/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context') {
|
||||
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
async function fire(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
signal: AbortSignal = SIGNAL,
|
||||
): Promise<void> {
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, '', [], signal)
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
@@ -90,185 +123,186 @@ class ScriptedAdapter extends LlmAdapter {
|
||||
|
||||
async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(timeContext, config)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('temporal section rendering', () => {
|
||||
it('renders the first turn in UTC with the explicit no-previous-message fallback', async () => {
|
||||
const { ctx } = await mount()
|
||||
function requestText(request: GenerateOptions): string {
|
||||
return request.messages
|
||||
.flatMap(message => message.content)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('durable step context', () => {
|
||||
it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
|
||||
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
|
||||
const session = new Session(SessionId('first'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toBe(
|
||||
'Current time: 2026-07-14T00:00:00+00:00[UTC]\n'
|
||||
+ 'Time since previous message: unavailable (no earlier message in this session).',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a non-UTC numeric offset and all compact duration units', async () => {
|
||||
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
|
||||
const session = new Session(SessionId('offset'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'previous' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
vi.setSystemTime(BASE + 90_061_000)
|
||||
openMessageTurn(session, 2)
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toBe(
|
||||
'Current time: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
|
||||
+ 'Time since previous message: 1d 1h 1m 1s.',
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toEqual([
|
||||
'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
|
||||
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
|
||||
])
|
||||
const event = session.events.at(-1)
|
||||
expect(event?.type).toBe('context/message')
|
||||
if (event?.type !== 'context/message') throw new Error('missing time context')
|
||||
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('unavailable'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'Elapsed since the preceding model-visible message: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps a backward wall-clock adjustment to a zero duration', async () => {
|
||||
it.each([
|
||||
['omitted interval', {}],
|
||||
['zero interval', { refreshIntervalMs: 0 }],
|
||||
] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => {
|
||||
const { ctx } = await mount(config)
|
||||
const session = new Session(SessionId('later-step'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 3)
|
||||
await fire(ctx, agent, 3, 1)
|
||||
vi.setSystemTime(BASE + 61_000)
|
||||
|
||||
await fire(ctx, agent, 3, 2)
|
||||
|
||||
expect(contextTexts(session)[1]).toBe(
|
||||
'Time sampled while preparing turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n'
|
||||
+ 'Elapsed since the preceding step context: 1m 1s.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('backward-duration'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'future by adjusted clock' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const session = new Session(SessionId('later-step-boundary'))
|
||||
openMessageTurn(session, 4)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 4, 2)
|
||||
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'Elapsed since the preceding step context: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unavailable later-step baseline when event lookup is exhausted', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('later-step-exhausted'))
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 2)
|
||||
|
||||
expect(contextTexts(session)[0]).toContain(
|
||||
'Elapsed since the preceding step context: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const session = new Session(SessionId('backward'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, agent, 1, 1)
|
||||
vi.setSystemTime(BASE - 5_000)
|
||||
openMessageTurn(session, 2)
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 0s.')
|
||||
await fire(ctx, agent, 1, 2)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(2)
|
||||
expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.')
|
||||
})
|
||||
|
||||
const previousMessageCases = [
|
||||
['user/message', (session: Session): void => {
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'u' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
}],
|
||||
['assistant/message', (session: Session): void => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
|
||||
}],
|
||||
['tool/result', (session: Session): void => {
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('previous'),
|
||||
content: [{ type: 'text', text: 'r' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
}],
|
||||
['context/message', (session: Session): void => {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'c' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}],
|
||||
['steering/message', (session: Session): void => {
|
||||
session.append('steering/message', {
|
||||
turn: 1,
|
||||
content: [{ type: 'text', text: 's' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}],
|
||||
] as const
|
||||
it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
||||
const original = new Session(SessionId('seed-source'))
|
||||
openMessageTurn(original, 1)
|
||||
await fire(ctx, sessionAgent(original), 1, 1)
|
||||
const user = original.events.find(event => event.type === 'user/message')
|
||||
const reading = original.events.find(event => event.type === 'context/message')
|
||||
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
|
||||
original.append('context/message', {
|
||||
content: [{ type: 'text', text: 'compacted history' }],
|
||||
source: { kind: 'plugin', plugin: 'compact-basic' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: user.seq, end: reading.seq },
|
||||
sourceEventSeqs: [user.seq, reading.seq],
|
||||
})
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
|
||||
|
||||
it.each(previousMessageCases)('uses a prior %s as the duration baseline', async (_name, appendPrevious) => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId(`previous-${_name}`))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
appendPrevious(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
vi.setSystemTime(BASE + 5_000)
|
||||
openMessageTurn(session, 2)
|
||||
const resumed = new Session(SessionId('resumed'), [...original.events])
|
||||
const resumedAgent = sessionAgent(resumed)
|
||||
vi.setSystemTime(BASE + 999)
|
||||
openMessageTurn(resumed, 2)
|
||||
const beforeSkip = resumed.events.length
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 5s.')
|
||||
})
|
||||
await fire(ctx, resumedAgent, 2, 1)
|
||||
|
||||
it('contributes empty text without an active agent turn', async () => {
|
||||
const { ctx } = await mount()
|
||||
expect(await sectionText(ctx)).toBe('')
|
||||
expect(resumed.events).toHaveLength(beforeSkip)
|
||||
expect(contextTexts(resumed)).toHaveLength(1)
|
||||
|
||||
const empty = sessionAgent(new Session(SessionId('empty')))
|
||||
expect(await sectionText(ctx, empty)).toBe('')
|
||||
|
||||
const closedSession = new Session(SessionId('closed'))
|
||||
openMessageTurn(closedSession, 1)
|
||||
closedSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(await sectionText(ctx, sessionAgent(closedSession))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh policy', () => {
|
||||
it('reuses within the interval, refreshes at expiry, and refreshes after a backward clock jump', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const session = new Session(SessionId('interval'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
const first = await sectionText(ctx, agent)
|
||||
vi.setSystemTime(BASE + 30_000)
|
||||
expect(await sectionText(ctx, agent)).toBe(first)
|
||||
vi.setSystemTime(BASE + 60_000)
|
||||
const expired = await sectionText(ctx, agent)
|
||||
expect(expired).toContain('2026-07-14T00:01:00+00:00[UTC]')
|
||||
vi.setSystemTime(BASE + 59_000)
|
||||
expect(await sectionText(ctx, agent)).toContain('2026-07-14T00:00:59+00:00[UTC]')
|
||||
})
|
||||
|
||||
it('refreshes every assembly when refreshIntervalMs is zero', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 0 })
|
||||
const session = new Session(SessionId('every-step'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
const first = await sectionText(ctx, agent)
|
||||
vi.setSystemTime(BASE + 1_000)
|
||||
expect(await sectionText(ctx, agent)).not.toBe(first)
|
||||
await fire(ctx, resumedAgent, 2, 2)
|
||||
|
||||
expect(contextTexts(resumed)).toHaveLength(2)
|
||||
expect(contextTexts(resumed)[1]).toContain(
|
||||
'Elapsed since the preceding step context: unavailable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('always refreshes for a new turn and keeps the preceding message baseline', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const session = new Session(SessionId('turn-refresh'))
|
||||
it('applies a positive interval across turns without sharing state between sessions', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
|
||||
const first = new Session(SessionId('interval-first'))
|
||||
const firstAgent = sessionAgent(first, 'first-agent')
|
||||
openMessageTurn(first, 1)
|
||||
await fire(ctx, firstAgent, 1, 1)
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
vi.setSystemTime(BASE + 500)
|
||||
openMessageTurn(first, 2)
|
||||
const beforeSkip = first.events.length
|
||||
await fire(ctx, firstAgent, 2, 1)
|
||||
|
||||
const independent = new Session(SessionId('interval-independent'))
|
||||
openMessageTurn(independent, 1)
|
||||
await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
|
||||
|
||||
expect(first.events).toHaveLength(beforeSkip)
|
||||
expect(contextTexts(first)).toHaveLength(1)
|
||||
expect(contextTexts(independent)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('ordering'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
const first = await sectionText(ctx, agent)
|
||||
vi.setSystemTime(BASE + 1_000)
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
vi.setSystemTime(BASE + 2_000)
|
||||
openMessageTurn(session, 2)
|
||||
let ordinarySawContext = false
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
|
||||
})
|
||||
|
||||
const second = await sectionText(ctx, agent)
|
||||
expect(second).not.toBe(first)
|
||||
expect(second).toContain('Time since previous message: 1s.')
|
||||
})
|
||||
await fire(ctx, agent, 1, 1)
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
await fire(ctx, agent, 1, 2, abort.signal)
|
||||
|
||||
it('keeps refresh caches independent per agent', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
|
||||
const sessionA = new Session(SessionId('agent-a'))
|
||||
const sessionB = new Session(SessionId('agent-b'))
|
||||
const agentA = sessionAgent(sessionA, 'a')
|
||||
const agentB = sessionAgent(sessionB, 'b')
|
||||
openMessageTurn(sessionA, 1)
|
||||
openMessageTurn(sessionB, 1)
|
||||
const aFirst = await sectionText(ctx, agentA)
|
||||
vi.setSystemTime(BASE + 30_000)
|
||||
const bFirst = await sectionText(ctx, agentB)
|
||||
vi.setSystemTime(BASE + 40_000)
|
||||
|
||||
expect(await sectionText(ctx, agentA)).toBe(aFirst)
|
||||
expect(bFirst).toContain('2026-07-14T00:00:30+00:00[UTC]')
|
||||
expect(ordinarySawContext).toBe(true)
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -280,49 +314,79 @@ describe('configuration and lifecycle', () => {
|
||||
const session = new Session(SessionId('system-zone'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toContain(
|
||||
'Current time: 2026-07-14T08:00:00+08:00[Asia/Shanghai]',
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)[0]).toContain('2026-07-14T08:00:00+08:00[Asia/Shanghai]')
|
||||
})
|
||||
|
||||
it('fails loud for an invalid explicit zone or an unavailable process zone', async () => {
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(AgentRegistry)
|
||||
await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(
|
||||
/invalid IANA timeZone/,
|
||||
)
|
||||
})
|
||||
|
||||
it('fails loud for negative, fractional, unsafe, and invalid-zone config', async () => {
|
||||
for (const refreshIntervalMs of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await expect(ctx.plugin(timeContext, { refreshIntervalMs })).rejects.toThrow(/non-negative safe integer/)
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await expect(ctx.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(/invalid IANA timeZone/)
|
||||
})
|
||||
|
||||
it('fails loud when the process system zone cannot be resolved', async () => {
|
||||
vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => {
|
||||
throw new RangeError('system zone unavailable')
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
await expect(ctx.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
|
||||
const unresolved = new Context()
|
||||
await unresolved.plugin(AgentRegistry)
|
||||
await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
|
||||
})
|
||||
|
||||
it('removes its section when the plugin fiber disposes', async () => {
|
||||
it('rejects invalid refresh intervals at plugin load with one diagnostic', async () => {
|
||||
const invalid = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, Number.POSITIVE_INFINITY, Number.NaN]
|
||||
for (const refreshIntervalMs of invalid) {
|
||||
await expect(mount({ refreshIntervalMs })).rejects.toThrow(
|
||||
'time-context: refreshIntervalMs must be a non-negative safe integer',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes its listener when the plugin fiber disposes', async () => {
|
||||
const { ctx, fiber } = await mount()
|
||||
const session = new Session(SessionId('dispose'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
expect(await sectionText(ctx, agent)).toContain('Current time:')
|
||||
await fire(ctx, agent, 1, 1)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(await sectionText(ctx, agent)).toBeUndefined()
|
||||
await fire(ctx, agent, 1, 2)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('real agent-loop request logging', () => {
|
||||
it('refreshes a long turn in the system prompt and records the header delta without context history', async () => {
|
||||
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')])
|
||||
const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 })
|
||||
describe('real agent-loop request history', () => {
|
||||
it.each([
|
||||
['throws', 'error'],
|
||||
['cancels', 'aborted'],
|
||||
] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => {
|
||||
const adapter = new ScriptedAdapter([textResponse('unused')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
let laterSawReading = false
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
laterSawReading = contextTexts(subject.session).length === 1
|
||||
if (mode === 'throws') throw new Error('later pre-step failure')
|
||||
subject.cancel({ kind: 'user' })
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId(`late-${mode}`), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(laterSawReading).toBe(true)
|
||||
expect(contextTexts(agent.session)).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
|
||||
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'tick',
|
||||
description: 'advance fake time',
|
||||
@@ -332,42 +396,57 @@ describe('real agent-loop request logging', () => {
|
||||
return [{ type: 'text' as const, text: 'advanced' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('loop'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(AgentId('loop'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]')
|
||||
expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]')
|
||||
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(1)
|
||||
expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system)
|
||||
|
||||
vi.setSystemTime(BASE + 361_000)
|
||||
agent.send([{ type: 'text', text: 'again' }])
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests[2]!.system).toContain('Time since previous message: 5m 0s.')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const contexts = agent.session.events.filter(event => event.type === 'context/message')
|
||||
const starts = agent.session.events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(adapter.requests.length)
|
||||
expect(starts).toHaveLength(adapter.requests.length)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
|
||||
}
|
||||
expect(contexts.every(event => event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context'
|
||||
&& event.surfaceOp === 'append')).toBe(true)
|
||||
|
||||
const firstRequestText = requestText(adapter.requests[0]!)
|
||||
const secondRequestText = requestText(adapter.requests[1]!)
|
||||
expect(firstRequestText).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: 0s.')
|
||||
expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.')
|
||||
|
||||
for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing')
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('real Loader export path', () => {
|
||||
it('keeps the namespace metadata and boots through unwrapExports', async () => {
|
||||
it('keeps namespace metadata and boots the agent listener through unwrapExports', async () => {
|
||||
expect('default' in timeContext).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(timeContext) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(timeContext)
|
||||
expect(unwrapped.name).toBe('time-context')
|
||||
expect(unwrapped.inject).toEqual(['systemPrompt'])
|
||||
expect(unwrapped.inject).toEqual(['agents'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
|
||||
await ctx.plugin(plugin)
|
||||
const session = new Session(SessionId('loader'))
|
||||
openMessageTurn(session, 1)
|
||||
expect(await sectionText(ctx, sessionAgent(session))).toContain('Current time:')
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/agent" }
|
||||
]
|
||||
}
|
||||
|
||||
140
packages/context/workspace-context/README.md
Normal file
140
packages/context/workspace-context/README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# @deepseek-ai/dsh-workspace-context
|
||||
|
||||
Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin freezes the initial user-global and project instruction chain into the request prefix, then discovers nested files and reports later changes or removals through durable context messages after successful filesystem tool calls.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by one configured instruction candidate in each directory from the project root to `agent.session.header.cwd`. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
|
||||
|
||||
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
|
||||
|
||||
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. Once `lstat` identifies the winning regular-file candidate, a later resolve/stat failure makes that scope temporarily unavailable instead of falling through to a lower-priority name. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
|
||||
|
||||
## Prompt Shape
|
||||
|
||||
Baseline instructions are request-only user-role prefix messages framed with the familiar system-reminder pattern:
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
|
||||
|
||||
Instructions from: ~/.dsh/AGENTS.md
|
||||
|
||||
...
|
||||
|
||||
Instructions from: AGENTS.md
|
||||
|
||||
...
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
Newly reached scopes use a durable raw `context/message`:
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
Additional instructions from: packages/app/AGENTS.md
|
||||
|
||||
These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.
|
||||
|
||||
...
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
|
||||
|
||||
The core `context/message` envelope is disabled for these messages because the plugin already owns the complete `<system-reminder>` framing. This is caller-selected with `envelope: 'raw'`; ordinary injected context still receives the canonical `<context source="...">` envelope.
|
||||
|
||||
## State And Refresh
|
||||
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives first because a later tool aborted the step and the loop discarded its context buffer, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
|
||||
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
|
||||
|
||||
The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix.
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts
|
||||
export interface Config {
|
||||
dshHome?: string
|
||||
projectRootMarkers?: string[]
|
||||
maxBytes: number
|
||||
maxSourceBytes?: number
|
||||
instructionFileCandidates?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
|
||||
|
||||
The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
|
||||
|
||||
## Budgeting And Bounded Reads
|
||||
|
||||
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
|
||||
|
||||
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored without falling through to a lower-priority same-directory candidate; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Baseline session prefix
|
||||
|
||||
**What the model sees**: At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order.
|
||||
|
||||
**Token effect**: The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
|
||||
#### Baseline instruction template
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
|
||||
|
||||
Instructions from: ~/.dsh/AGENTS.md
|
||||
|
||||
<user-global-instructions>
|
||||
|
||||
Instructions from: AGENTS.md
|
||||
|
||||
<project-instructions>
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
### Newly discovered scope context
|
||||
|
||||
**What the model sees**: After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
|
||||
|
||||
**Token effect**: Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
|
||||
|
||||
#### Additional instruction template
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
Additional instructions from: packages/app/AGENTS.md
|
||||
|
||||
These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.
|
||||
|
||||
<nested-instructions>
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
### Changed or removed instruction context
|
||||
|
||||
**What the model sees**: A changed file produces `Updated instructions from: <path>` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below.
|
||||
|
||||
**Token effect**: Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch.
|
||||
|
||||
#### Removal notice
|
||||
|
||||
```markdown
|
||||
<system-reminder>
|
||||
Instructions removed: packages/app/AGENTS.md
|
||||
|
||||
The previously loaded instructions from this file no longer apply.
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
|
||||
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix.
|
||||
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; same-directory names such as `CLAUDE.local.md` require explicit `instructionFileCandidates` configuration.
|
||||
- **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose.
|
||||
52
packages/context/workspace-context/package.json
Normal file
52
packages/context/workspace-context/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-workspace-context",
|
||||
"description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-execution": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
82
packages/context/workspace-context/src/config.ts
Normal file
82
packages/context/workspace-context/src/config.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Configuration normalization for workspace instruction discovery and rendering.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/config
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
|
||||
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
|
||||
const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
|
||||
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
|
||||
|
||||
/** User-facing workspace instruction loader configuration. */
|
||||
export interface Config {
|
||||
/** Harness home containing the fixed user-global `AGENTS.md`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Directory entries that identify the project root while walking upward from the session cwd. */
|
||||
projectRootMarkers?: string[]
|
||||
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
|
||||
maxBytes: number
|
||||
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
|
||||
maxSourceBytes?: number
|
||||
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
|
||||
instructionFileCandidates?: string[]
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
|
||||
maxBytes: z.number().required(),
|
||||
maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
|
||||
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
|
||||
})
|
||||
|
||||
/** Normalized instruction discovery configuration. */
|
||||
export interface ResolvedDiscoveryConfig {
|
||||
dshHome: string
|
||||
projectRootMarkers: string[]
|
||||
instructionFileCandidates: string[]
|
||||
}
|
||||
|
||||
/** Normalized configuration used by discovery and reconciliation. */
|
||||
export interface ResolvedConfig extends ResolvedDiscoveryConfig {
|
||||
maxBytes: number
|
||||
maxSourceBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve defaults, the harness home, and valid same-directory candidates.
|
||||
* @param config - user-facing plugin configuration.
|
||||
* @returns normalized runtime configuration.
|
||||
*/
|
||||
export function resolveConfig(config: Config): ResolvedConfig {
|
||||
return {
|
||||
...resolveDiscoveryConfig(config),
|
||||
maxBytes: config.maxBytes,
|
||||
maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the subset of configuration used before instruction content is rendered.
|
||||
* @param config - optional discovery controls.
|
||||
* @returns normalized home, root markers, and instruction candidates.
|
||||
*/
|
||||
export function resolveDiscoveryConfig(
|
||||
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates'>,
|
||||
): ResolvedDiscoveryConfig {
|
||||
return {
|
||||
dshHome: resolveDshHome(config.dshHome),
|
||||
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
|
||||
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstructionFileCandidates(candidates: string[] | undefined): string[] {
|
||||
return (candidates ?? [...DEFAULT_INSTRUCTION_FILE_CANDIDATES]).filter(candidate => (
|
||||
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
|
||||
))
|
||||
}
|
||||
16
packages/context/workspace-context/src/digest.ts
Normal file
16
packages/context/workspace-context/src/digest.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Content identity for workspace instruction duplicate suppression.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/digest
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Compute the content identity used across instruction loading and session state.
|
||||
* @param content - exact UTF-8 instruction text.
|
||||
* @returns lowercase SHA-1 digest in hexadecimal form.
|
||||
*/
|
||||
export function instructionContentSha1(content: string): string {
|
||||
return createHash('sha1').update(content).digest('hex')
|
||||
}
|
||||
473
packages/context/workspace-context/src/files.ts
Normal file
473
packages/context/workspace-context/src/files.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Instruction-file discovery and bounded, abort-aware provider reads.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/files
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { lstat, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
export interface InstructionFile {
|
||||
absolutePath: string
|
||||
displayPath: string
|
||||
}
|
||||
|
||||
/** An instruction file whose UTF-8 content was read successfully. */
|
||||
export interface LoadedInstructionFile extends InstructionFile {
|
||||
content: string
|
||||
/** Provider freshness token when the file was loaded through `ctx.fs`. */
|
||||
version?: FsVersion
|
||||
}
|
||||
|
||||
interface DiscoveredInstructionFile extends InstructionFile {
|
||||
target?: FsTarget
|
||||
size?: number
|
||||
version?: FsVersion
|
||||
}
|
||||
|
||||
/** Provider metadata for a winning scope candidate before its content is read. */
|
||||
export interface ProbedInstructionFile extends InstructionFile {
|
||||
target: FsTarget
|
||||
version: FsVersion
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface DiscoverOptions {
|
||||
cwd: string
|
||||
dshHome?: string
|
||||
projectRootMarkers?: string[]
|
||||
instructionFileCandidates?: string[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface LoadOptions extends DiscoverOptions {
|
||||
maxBytes: number
|
||||
maxSourceBytes?: number
|
||||
}
|
||||
|
||||
/** Rendered baseline plus the files that survived byte budgeting. */
|
||||
export interface RenderedInstructionSet {
|
||||
rendered: RenderedWorkspaceContext
|
||||
included: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
||||
export type ScopeInstructionProbe =
|
||||
| { kind: 'present'; file: ProbedInstructionFile }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
interface StatFileInfo {
|
||||
target?: FsTarget
|
||||
size?: number
|
||||
version?: FsVersion
|
||||
}
|
||||
|
||||
type StatFileProbe =
|
||||
| { kind: 'present'; info: StatFileInfo }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
function signalOptions(signal?: AbortSignal): { signal: AbortSignal } | undefined {
|
||||
return signal === undefined ? undefined : { signal }
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
}
|
||||
|
||||
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
const info = await lstat(path)
|
||||
signal?.throwIfAborted()
|
||||
if (!info.isFile()) return { kind: 'absent' }
|
||||
return { kind: 'present', info: { size: info.size } }
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
return isMissingPathError(error) ? { kind: 'absent' } : { kind: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
async function fsStatFile(
|
||||
path: string,
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StatFileProbe> {
|
||||
// TODO(instruction-symlink-race): replace this lstat -> resolve -> read
|
||||
// protocol, including probeScopeInstruction below, with a provider-owned
|
||||
// atomic no-follow read so the final component cannot change after validation.
|
||||
let pathInfo: FsPathInfo | undefined
|
||||
try {
|
||||
pathInfo = await fileSystem.lstat(path, undefined, signal)
|
||||
signal?.throwIfAborted()
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (pathInfo?.type !== 'file') return { kind: 'absent' }
|
||||
|
||||
try {
|
||||
const target = await fileSystem.resolve(path, signalOptions(signal))
|
||||
signal?.throwIfAborted()
|
||||
const info = await fileSystem.stat(target, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (info?.type !== 'file') return { kind: 'unavailable' }
|
||||
return {
|
||||
kind: 'present',
|
||||
info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
|
||||
}
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
async function statFile(
|
||||
path: string,
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StatFileProbe> {
|
||||
return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal)
|
||||
}
|
||||
|
||||
async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: AbortSignal): Promise<boolean> {
|
||||
if (fileSystem !== undefined) {
|
||||
try {
|
||||
const target = await fileSystem.resolve(path, signalOptions(signal))
|
||||
return await fileSystem.stat(target, signal) !== undefined
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
// TODO(root-marker-unavailable): preserve provider failure separately from
|
||||
// absence and stop discovery; continuing upward can cross into an ancestor project.
|
||||
return false
|
||||
}
|
||||
}
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
await stat(path)
|
||||
signal?.throwIfAborted()
|
||||
return true
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upward to the first directory containing a configured root marker.
|
||||
* @param cwd - absolute session working directory where the walk begins.
|
||||
* @param markers - child names that identify a project root.
|
||||
* @param fileSystem - optional provider used instead of host filesystem probes.
|
||||
* @param signal - cancellation for provider and host probes.
|
||||
* @returns the discovered project root, or `cwd` when no marker exists.
|
||||
*/
|
||||
export async function findProjectRoot(
|
||||
cwd: string,
|
||||
markers: readonly string[],
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
let current = resolve(cwd)
|
||||
for (;;) {
|
||||
for (const marker of markers) {
|
||||
if (await existsAsMarker(join(current, marker), fileSystem, signal)) return current
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return resolve(cwd)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the inclusive root-to-cwd directory chain.
|
||||
* @param root - root directory expected to contain or equal `cwd`.
|
||||
* @param cwd - most-specific directory in the chain.
|
||||
* @returns directories ordered from broadest to most specific.
|
||||
*/
|
||||
export function ancestorChain(root: string, cwd: string): string[] {
|
||||
const chain: string[] = []
|
||||
let current = resolve(cwd)
|
||||
const resolvedRoot = resolve(root)
|
||||
while (current !== resolvedRoot) {
|
||||
chain.push(current)
|
||||
const parent = dirname(current)
|
||||
/* v8 ignore next -- discovery always supplies cwd or an ancestor root. */
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
chain.push(resolvedRoot)
|
||||
return chain.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find descendant directories crossed between a cwd and a touched file.
|
||||
* @param root - session cwd that bounds nested discovery.
|
||||
* @param touchedPath - absolute path or path relative to `root`.
|
||||
* @returns descendant directories from shallowest through the touched file's parent.
|
||||
*/
|
||||
export function descendantDirsBetween(root: string, touchedPath: string): string[] {
|
||||
const resolvedRoot = resolve(root)
|
||||
const targetPath = isAbsolute(touchedPath) ? resolve(touchedPath) : resolve(resolvedRoot, touchedPath)
|
||||
const targetDir = dirname(targetPath)
|
||||
const rel = relative(resolvedRoot, targetDir)
|
||||
if (rel.length === 0 || rel.startsWith('..') || isAbsolute(rel)) return []
|
||||
return ancestorChain(resolvedRoot, targetDir).slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an absolute instruction path to its project-root-relative display form.
|
||||
* @param root - project root used as the display base.
|
||||
* @param path - absolute path to display.
|
||||
* @returns the root-relative path.
|
||||
*/
|
||||
export function relativeDisplay(root: string, path: string): string {
|
||||
return relative(root, path)
|
||||
}
|
||||
|
||||
async function firstExistingInstructionFile(
|
||||
dir: string,
|
||||
root: string,
|
||||
instructionFileCandidates: readonly string[],
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DiscoveredInstructionFile | undefined> {
|
||||
for (const candidate of instructionFileCandidates) {
|
||||
const path = join(dir, candidate)
|
||||
const probe = await statFile(path, fileSystem, signal)
|
||||
switch (probe.kind) {
|
||||
case 'present':
|
||||
return {
|
||||
absolutePath: path,
|
||||
displayPath: relativeDisplay(root, path),
|
||||
...probe.info,
|
||||
}
|
||||
case 'absent':
|
||||
continue
|
||||
case 'unavailable':
|
||||
return undefined
|
||||
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
|
||||
default:
|
||||
return assertNever(probe, 'StatFileProbe')
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function discoverInstructionFiles(
|
||||
options: DiscoverOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<DiscoveredInstructionFile[]> {
|
||||
const config = resolveDiscoveryConfig(options)
|
||||
const files: DiscoveredInstructionFile[] = []
|
||||
const seen = new Set<string>()
|
||||
const addFile = (file: DiscoveredInstructionFile): void => {
|
||||
if (seen.has(file.absolutePath)) return
|
||||
seen.add(file.absolutePath)
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
const userGlobal = join(config.dshHome, 'AGENTS.md')
|
||||
const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
|
||||
switch (userGlobalProbe.kind) {
|
||||
case 'present':
|
||||
addFile({
|
||||
absolutePath: userGlobal,
|
||||
displayPath: userGlobalDisplayPath(config.dshHome),
|
||||
...userGlobalProbe.info,
|
||||
})
|
||||
break
|
||||
case 'absent':
|
||||
case 'unavailable':
|
||||
break
|
||||
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
|
||||
default:
|
||||
assertNever(userGlobalProbe, 'StatFileProbe')
|
||||
}
|
||||
|
||||
const cwd = resolve(options.cwd)
|
||||
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) {
|
||||
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem, options.signal)
|
||||
if (file !== undefined) addFile(file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover host-visible user-global and root-to-cwd instruction candidates.
|
||||
* @param options - cwd, home, root marker, and candidate configuration.
|
||||
* @returns de-duplicated instruction paths in model precedence order.
|
||||
*/
|
||||
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
|
||||
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
|
||||
}
|
||||
|
||||
async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterable<string> {
|
||||
const stream = createReadStream(path, { encoding: 'utf8', signal })
|
||||
for await (const chunk of stream) yield String(chunk)
|
||||
}
|
||||
|
||||
async function readBounded(
|
||||
file: DiscoveredInstructionFile,
|
||||
maxSourceBytes: number,
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
// TODO(total-instruction-read-bound): enforce an aggregate source budget
|
||||
// across a complete baseline or reconciliation batch; the render budget is
|
||||
// applied only after every accepted file has been read under this per-file cap.
|
||||
signal?.throwIfAborted()
|
||||
if (file.size !== undefined && file.size > maxSourceBytes) return undefined
|
||||
try {
|
||||
const chunks = fileSystem === undefined || file.target === undefined
|
||||
? nodeTextChunks(file.absolutePath, signal)
|
||||
: await fileSystem.streamText(file.target, signal)
|
||||
const parts: string[] = []
|
||||
let bytes = 0
|
||||
for await (const chunk of chunks) {
|
||||
signal?.throwIfAborted()
|
||||
bytes += Buffer.byteLength(chunk, 'utf8')
|
||||
if (bytes > maxSourceBytes) return undefined
|
||||
parts.push(chunk)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return parts.join('')
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
// A file may disappear or become unreadable after its metadata probe.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover, read, and render the baseline instruction chain.
|
||||
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
|
||||
* @param fileSystem - optional provider used instead of host filesystem reads.
|
||||
* @returns rendered baseline context, or undefined when nothing can be loaded.
|
||||
*/
|
||||
export async function loadBaselineInstructions(
|
||||
options: LoadOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<RenderedWorkspaceContext | undefined> {
|
||||
return (await loadBaselineInstructionSet(options, fileSystem))?.rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a baseline together with the files retained after rendering.
|
||||
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
|
||||
* @param fileSystem - optional provider used instead of host filesystem reads.
|
||||
* @returns rendered context and retained files, or undefined when empty or disabled.
|
||||
*/
|
||||
export async function loadBaselineInstructionSet(
|
||||
options: LoadOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<RenderedInstructionSet | undefined> {
|
||||
const config = resolveConfig(options)
|
||||
if (config.maxBytes <= 0 || !Number.isFinite(config.maxBytes)) return undefined
|
||||
if (config.maxSourceBytes <= 0 || !Number.isFinite(config.maxSourceBytes)) return undefined
|
||||
const discovered = await discoverInstructionFiles(options, fileSystem)
|
||||
const loaded: LoadedInstructionFile[] = []
|
||||
for (const file of discovered) {
|
||||
const content = await readBounded(file, config.maxSourceBytes, fileSystem, options.signal)
|
||||
if (content !== undefined) {
|
||||
loaded.push({
|
||||
absolutePath: file.absolutePath,
|
||||
displayPath: file.displayPath,
|
||||
content,
|
||||
...file.version === undefined ? {} : { version: file.version },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (loaded.length === 0) return undefined
|
||||
const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes })
|
||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
||||
return { rendered, included: loaded.filter(file => !omitted.has(file.absolutePath)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the current first-winning instruction candidate for one logical scope.
|
||||
* @param scope - `user-global`, `.`, or a project-relative directory.
|
||||
* @param projectRoot - project root used to resolve and display project scopes.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param fileSystem - provider used for no-follow probing.
|
||||
* @param signal - cancellation for provider probes.
|
||||
* @returns present metadata, confirmed absence, or temporary unavailability.
|
||||
*/
|
||||
export async function probeScopeInstruction(
|
||||
scope: string,
|
||||
projectRoot: string,
|
||||
resolved: ResolvedConfig,
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ScopeInstructionProbe> {
|
||||
const dir = scope === 'user-global'
|
||||
? resolved.dshHome
|
||||
: scope === '.' ? projectRoot : join(projectRoot, scope)
|
||||
const candidates = scope === 'user-global' ? ['AGENTS.md'] : resolved.instructionFileCandidates
|
||||
for (const candidate of candidates) {
|
||||
const absolutePath = join(dir, candidate)
|
||||
let pathInfo: FsPathInfo | undefined
|
||||
try {
|
||||
pathInfo = await fileSystem.lstat(absolutePath, undefined, signal)
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (pathInfo === undefined || pathInfo.type !== 'file') continue
|
||||
let target: FsTarget
|
||||
let info: FsInfo | undefined
|
||||
try {
|
||||
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
|
||||
info = await fileSystem.stat(target, signal)
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (info?.type !== 'file') return { kind: 'unavailable' }
|
||||
const file: ProbedInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
target,
|
||||
version: info.version,
|
||||
...info.size === undefined ? {} : { size: info.size },
|
||||
}
|
||||
return { kind: 'present', file }
|
||||
}
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one already-probed scope candidate under the configured source cap.
|
||||
* @param file - winning provider candidate and its metadata snapshot.
|
||||
* @param maxSourceBytes - maximum UTF-8 bytes accepted from the source.
|
||||
* @param fileSystem - provider used for the streaming read.
|
||||
* @param signal - cancellation for provider streaming.
|
||||
* @returns loaded content with the probed version, or undefined when unavailable.
|
||||
*/
|
||||
export async function readScopeInstruction(
|
||||
file: ProbedInstructionFile,
|
||||
maxSourceBytes: number,
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LoadedInstructionFile | undefined> {
|
||||
const content = await readBounded(file, maxSourceBytes, fileSystem, signal)
|
||||
if (content === undefined) return undefined
|
||||
return {
|
||||
absolutePath: file.absolutePath,
|
||||
displayPath: file.displayPath,
|
||||
content,
|
||||
version: file.version,
|
||||
}
|
||||
}
|
||||
|
||||
function userGlobalDisplayPath(dshHome: string): string {
|
||||
return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
|
||||
}
|
||||
173
packages/context/workspace-context/src/index.ts
Normal file
173
packages/context/workspace-context/src/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Workspace instruction loader for AGENTS.md-compatible files.
|
||||
*
|
||||
* Baseline instructions are frozen into `agent/session-prefix`; successful fs
|
||||
* tool touches reconcile nested, changed, and removed instructions through
|
||||
* `tools/post-execute` for the next model request. Plugin lifecycle reads use
|
||||
* the optional `ctx.fs` provider, so providerless products mount it as a no-op.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
|
||||
import { loadBaselineInstructionSet } from './files.ts'
|
||||
import {
|
||||
applyInstructionVersionUpdates,
|
||||
baselineInstructionState,
|
||||
commitPendingInstructionContexts,
|
||||
dynamicInstructionContext,
|
||||
name,
|
||||
observeInstructionSessionEvent,
|
||||
reconcileInstructionContext,
|
||||
retainedInstructionVersionUpdates,
|
||||
rollbackPendingInstructionChanges,
|
||||
workspaceContextMessage,
|
||||
type InstructionVersionCache,
|
||||
type InstructionVersionUpdate,
|
||||
type PendingInstructionChange,
|
||||
} from './state.ts'
|
||||
import type { WorkspaceInstructionChange } from './render.ts'
|
||||
|
||||
export { Config, name }
|
||||
export {
|
||||
discoverBaselineInstructionFiles,
|
||||
loadBaselineInstructions,
|
||||
} from './files.ts'
|
||||
export type {
|
||||
InstructionFile,
|
||||
LoadedInstructionFile,
|
||||
} from './files.ts'
|
||||
export { renderWorkspaceContext } from './render.ts'
|
||||
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved: ResolvedConfig = resolveConfig(config)
|
||||
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
|
||||
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
|
||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
|
||||
const pendingByParent = new Map<ToolExecutionToken, {
|
||||
agent: Agent
|
||||
changes: WorkspaceInstructionChange[]
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}>()
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
|
||||
})
|
||||
|
||||
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
|
||||
const rest = await next()
|
||||
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) return rest
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const instructions = await loadBaselineInstructionSet({
|
||||
cwd,
|
||||
dshHome: resolved.dshHome,
|
||||
projectRootMarkers: resolved.projectRootMarkers,
|
||||
maxBytes: resolved.maxBytes,
|
||||
maxSourceBytes: resolved.maxSourceBytes,
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
signal,
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
baselineInstructionStates.set(agent.session, baseline.changes)
|
||||
instructionVersions.set(agent.session, baseline.versions)
|
||||
|
||||
const update = await reconcileInstructionContext(
|
||||
agent,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
baselineInstructionStates,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{ includeBaselineScopes: false, signal },
|
||||
)
|
||||
if (update !== undefined) {
|
||||
agent.inject(update.context.content, {
|
||||
source: update.context.source,
|
||||
envelope: update.context.envelope,
|
||||
meta: update.context.meta,
|
||||
})
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
if (instructions === undefined || instructions.rendered.text.length === 0) return rest
|
||||
return [workspaceContextMessage(instructions.rendered.text), ...rest]
|
||||
})
|
||||
|
||||
ctx.on('tools/post-execute', async (
|
||||
exec: ToolExecution,
|
||||
result: ToolExecutionResult,
|
||||
next,
|
||||
): Promise<PostToolDecision> => {
|
||||
const downstream = await next()
|
||||
// A downstream listener/policy blocked this call: the registry turns it
|
||||
// into a final `isError` result, so treat it like a failed fs touch and
|
||||
// load nothing. Reconciling here would surface workspace instructions from
|
||||
// a call the pipeline rejected, violating the "successful fs tool touches"
|
||||
// contract, and would advance the nested/baseline tracking state off a
|
||||
// touch that never really happened.
|
||||
if (downstream.kind === 'block') return downstream
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) return downstream
|
||||
const update = await dynamicInstructionContext(
|
||||
exec.agent,
|
||||
exec,
|
||||
result,
|
||||
resolved,
|
||||
pendingNestedChanges,
|
||||
baselineInstructionStates,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
)
|
||||
if (update === undefined) return downstream
|
||||
pendingVersionUpdates.set(exec.token, update.versionUpdates)
|
||||
return {
|
||||
kind: 'accept',
|
||||
...downstream.content !== undefined ? { content: downstream.content } : {},
|
||||
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
|
||||
pendingVersionUpdates.delete(exec.token)
|
||||
if (exec.parent !== undefined) {
|
||||
if (exec.agent === undefined) return
|
||||
// Child contexts participate in duplicate suppression within one composite
|
||||
// run, but remain provisional until the parent reaches its final policy.
|
||||
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
if (changes.length === 0) return
|
||||
const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
|
||||
const staged = pendingByParent.get(exec.parent)
|
||||
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
|
||||
else {
|
||||
staged.changes.push(...changes)
|
||||
staged.versionUpdates.push(...versionUpdates)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The parent result is authoritative: remove every provisional child change,
|
||||
// then commit only contexts that survived outer post-execute policy.
|
||||
const staged = pendingByParent.get(exec.token)
|
||||
if (staged !== undefined) {
|
||||
pendingByParent.delete(exec.token)
|
||||
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
|
||||
}
|
||||
if (exec.agent === undefined) return
|
||||
const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
|
||||
const stagedVersionUpdates = staged?.versionUpdates ?? []
|
||||
const versionUpdates = retainedInstructionVersionUpdates(
|
||||
[...stagedVersionUpdates, ...ownVersionUpdates],
|
||||
committed,
|
||||
)
|
||||
applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
|
||||
})
|
||||
}
|
||||
255
packages/context/workspace-context/src/render.ts
Normal file
255
packages/context/workspace-context/src/render.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Model-facing workspace instruction rendering within an explicit byte budget.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/render
|
||||
*/
|
||||
|
||||
import { dirname } from 'node:path'
|
||||
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
|
||||
|
||||
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
|
||||
const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
|
||||
const WORKSPACE_CONTEXT_INTRO = 'The following workspace instructions may be relevant to your work. '
|
||||
+ 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. '
|
||||
+ 'They do not override system, developer, or direct user instructions.'
|
||||
const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
|
||||
|
||||
/** Byte-accounting record for one truncated instruction file. */
|
||||
export interface TruncatedInstruction {
|
||||
displayPath: string
|
||||
originalBytes: number
|
||||
includedBytes: number
|
||||
}
|
||||
|
||||
/** Model-facing text plus omitted and truncated source records. */
|
||||
export interface RenderedWorkspaceContext {
|
||||
text: string
|
||||
omitted: InstructionFile[]
|
||||
truncated: TruncatedInstruction[]
|
||||
}
|
||||
|
||||
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
||||
export interface WorkspaceInstructionChange {
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
scope: string
|
||||
path: string
|
||||
previousPath?: string
|
||||
digest?: string
|
||||
}
|
||||
|
||||
/** One state transition paired with the content used to render it. */
|
||||
export interface ChangeRenderItem {
|
||||
change: WorkspaceInstructionChange
|
||||
file: LoadedInstructionFile
|
||||
}
|
||||
|
||||
interface RenderStyle {
|
||||
intro: string
|
||||
section(file: LoadedInstructionFile): string
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return Buffer.byteLength(value, 'utf8')
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8')
|
||||
while (byteLength(truncated) > maxBytes) {
|
||||
truncated = truncated.slice(0, -1)
|
||||
}
|
||||
return truncated
|
||||
}
|
||||
|
||||
function escapeInstructionContent(content: string): string {
|
||||
// TODO(instruction-frame-paths): apply the same delimiter neutralization to
|
||||
// every interpolated path, scope, and previous path; repository-controlled
|
||||
// names can otherwise close the plugin-owned system-reminder frame.
|
||||
return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
|
||||
}
|
||||
|
||||
function sectionText(file: LoadedInstructionFile): string {
|
||||
return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the logical instruction scope from a model-facing path.
|
||||
* @param displayPath - project-relative or user-global instruction path.
|
||||
* @returns `user-global`, `.`, or the containing project-relative directory.
|
||||
*/
|
||||
export function scopeForDisplayPath(displayPath: string): string {
|
||||
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return 'user-global'
|
||||
return dirname(displayPath)
|
||||
}
|
||||
|
||||
function additionalSectionText(file: LoadedInstructionFile): string {
|
||||
const scope = scopeForDisplayPath(file.displayPath)
|
||||
return [
|
||||
`Additional instructions from: ${file.displayPath}`,
|
||||
'',
|
||||
`These instructions apply to work under \`${scope}\`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.`,
|
||||
'',
|
||||
escapeInstructionContent(file.content),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText }
|
||||
|
||||
function changedSectionText(item: ChangeRenderItem): string {
|
||||
const { change, file } = item
|
||||
if (change.action === 'set') return additionalSectionText(file)
|
||||
if (change.action === 'remove') {
|
||||
return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`
|
||||
}
|
||||
const description = change.previousPath === undefined
|
||||
? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.'
|
||||
: `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${change.scope}\` instead.`
|
||||
return [
|
||||
`Updated instructions from: ${change.path}`,
|
||||
'',
|
||||
description,
|
||||
'',
|
||||
escapeInstructionContent(file.content),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one reconciliation batch and retain only transitions that fit.
|
||||
* @param items - ordered state transitions and current file contents.
|
||||
* @param maxBytes - maximum UTF-8 bytes allowed in the rendered batch.
|
||||
* @returns bounded prompt text and the transitions actually represented by it.
|
||||
*/
|
||||
export function renderInstructionChanges(
|
||||
items: ChangeRenderItem[],
|
||||
maxBytes: number,
|
||||
): { text: string; changes: WorkspaceInstructionChange[] } {
|
||||
const byAbsolutePath = new Map(items.map(item => [item.file.absolutePath, item]))
|
||||
const style: RenderStyle = {
|
||||
intro: '',
|
||||
section(file) {
|
||||
const item = byAbsolutePath.get(file.absolutePath)
|
||||
/* v8 ignore next -- the renderer receives exactly the files used to construct this map. */
|
||||
return item === undefined ? '' : changedSectionText({ ...item, file })
|
||||
},
|
||||
}
|
||||
const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
|
||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
||||
return {
|
||||
text: rendered.text,
|
||||
// TODO(rendered-change-proof): retain a transition only when its semantic
|
||||
// notice survived rendering; a tiny compact budget can currently return
|
||||
// unrelated notice text while still committing the full state transition.
|
||||
changes: items.filter(item => !omitted.has(item.file.absolutePath)).map(item => item.change),
|
||||
}
|
||||
}
|
||||
|
||||
function markerText(maxBytes: number, omitted: InstructionFile[], truncated: TruncatedInstruction[]): string {
|
||||
if (omitted.length === 0 && truncated.length === 0) return ''
|
||||
const parts: string[] = []
|
||||
if (omitted.length > 0) {
|
||||
parts.push(`omitted ${omitted.map(file => file.displayPath).join(', ')}`)
|
||||
}
|
||||
if (truncated.length > 0) {
|
||||
parts.push(`truncated ${truncated.map(item => `${item.displayPath} from ${item.originalBytes} to ${item.includedBytes} bytes`).join(', ')}`)
|
||||
}
|
||||
return `Workspace instruction budget ${maxBytes} bytes: ${parts.join('; ')}`
|
||||
}
|
||||
|
||||
function buildInstructionText(
|
||||
files: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
omitted: InstructionFile[],
|
||||
truncated: TruncatedInstruction[],
|
||||
style: RenderStyle,
|
||||
): string {
|
||||
const marker = markerText(maxBytes, omitted, truncated)
|
||||
const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0)
|
||||
return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n')
|
||||
}
|
||||
|
||||
function withTruncatedContent(file: LoadedInstructionFile, includedBytes: number): LoadedInstructionFile {
|
||||
return { ...file, content: truncateUtf8(file.content, includedBytes) }
|
||||
}
|
||||
|
||||
function truncateToFit(
|
||||
file: LoadedInstructionFile,
|
||||
includedFiles: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
omitted: InstructionFile[],
|
||||
style: RenderStyle,
|
||||
): LoadedInstructionFile {
|
||||
const originalBytes = byteLength(file.content)
|
||||
let low = 0
|
||||
let high = originalBytes
|
||||
let best = withTruncatedContent(file, 0)
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
const candidate = withTruncatedContent(file, mid)
|
||||
const truncated = [{ displayPath: file.displayPath, originalBytes, includedBytes: byteLength(candidate.content) }]
|
||||
const text = buildInstructionText([...includedFiles, candidate], maxBytes, omitted, truncated, style)
|
||||
if (byteLength(text) <= maxBytes) {
|
||||
best = candidate
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function renderInstructionContext(
|
||||
files: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
style: RenderStyle,
|
||||
): RenderedWorkspaceContext {
|
||||
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] }
|
||||
|
||||
const fullText = buildInstructionText(files, maxBytes, [], [], style)
|
||||
if (byteLength(fullText) <= maxBytes) return { text: fullText, omitted: [], truncated: [] }
|
||||
|
||||
for (let start = 1; start < files.length; start += 1) {
|
||||
const included = files.slice(start)
|
||||
const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||
const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
|
||||
if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [] }
|
||||
}
|
||||
|
||||
const mostSpecific = files.at(-1)
|
||||
/* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
|
||||
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [] }
|
||||
const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||
|
||||
for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
|
||||
const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle)
|
||||
const truncated = [{
|
||||
displayPath: mostSpecific.displayPath,
|
||||
originalBytes: byteLength(mostSpecific.content),
|
||||
includedBytes: byteLength(truncatedFile.content),
|
||||
}]
|
||||
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
|
||||
if (byteLength(text) <= maxBytes) return { text, omitted, truncated }
|
||||
}
|
||||
|
||||
const truncated = [{
|
||||
displayPath: mostSpecific.displayPath,
|
||||
originalBytes: byteLength(mostSpecific.content),
|
||||
includedBytes: 0,
|
||||
}]
|
||||
const compactNotice = markerText(maxBytes, omitted, truncated)
|
||||
const compactWithHeading = [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n')
|
||||
if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated }
|
||||
const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
|
||||
return { text, omitted, truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the baseline instruction chain with deterministic precedence budgeting.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - required rendering byte budget.
|
||||
* @returns bounded baseline prompt text and budget diagnostics.
|
||||
*/
|
||||
export function renderWorkspaceContext(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number },
|
||||
): RenderedWorkspaceContext {
|
||||
return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
|
||||
}
|
||||
507
packages/context/workspace-context/src/state.ts
Normal file
507
packages/context/workspace-context/src/state.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Session-visible workspace instruction state and dynamic reconciliation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/state
|
||||
*/
|
||||
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1 } from './digest.ts'
|
||||
import {
|
||||
ancestorChain,
|
||||
descendantDirsBetween,
|
||||
findProjectRoot,
|
||||
probeScopeInstruction,
|
||||
readScopeInstruction,
|
||||
relativeDisplay,
|
||||
type LoadedInstructionFile,
|
||||
} from './files.ts'
|
||||
import {
|
||||
renderInstructionChanges,
|
||||
scopeForDisplayPath,
|
||||
type ChangeRenderItem,
|
||||
type WorkspaceInstructionChange,
|
||||
} from './render.ts'
|
||||
|
||||
export const name = 'workspace-context'
|
||||
|
||||
const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const
|
||||
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
|
||||
|
||||
/** Dynamic state waiting for the loop to append its returned context event. */
|
||||
export interface PendingInstructionChange {
|
||||
change: WorkspaceInstructionChange
|
||||
afterSeq: number
|
||||
step?: { turn: number; step: number }
|
||||
}
|
||||
|
||||
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
|
||||
export interface InstructionVersionState {
|
||||
path: string
|
||||
version: FsVersion
|
||||
digest: string
|
||||
}
|
||||
|
||||
/** Session-isolated fast-path state keyed by logical instruction scope. */
|
||||
export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
|
||||
|
||||
/** A cache transition coupled to the model-visible change that authorizes it. */
|
||||
export interface InstructionVersionUpdate {
|
||||
change: WorkspaceInstructionChange
|
||||
state?: InstructionVersionState
|
||||
}
|
||||
|
||||
/** Rendered reconciliation plus cache transitions awaiting final policy. */
|
||||
export interface ReconciledInstructionContext {
|
||||
context: WorkspaceHookContext
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}
|
||||
|
||||
/** Plugin-owned raw context with required replay metadata. */
|
||||
export interface WorkspaceHookContext extends HookContext {
|
||||
envelope: 'raw'
|
||||
meta: JsonValue
|
||||
}
|
||||
|
||||
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
|
||||
const serializedChanges: JsonValue[] = changes.map(change => ({
|
||||
action: change.action,
|
||||
scope: change.scope,
|
||||
path: change.path,
|
||||
...change.previousPath !== undefined ? { previousPath: change.previousPath } : {},
|
||||
...change.digest !== undefined ? { digest: change.digest } : {},
|
||||
}))
|
||||
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
|
||||
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request-prefix message for a rendered baseline.
|
||||
* @param text - complete plugin-owned system-reminder text.
|
||||
* @returns a user-role prefix message.
|
||||
*/
|
||||
export function workspaceContextMessage(text: string): Message {
|
||||
return { role: 'user', content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
function filePathFromExecution(exec: ToolExecution): string | undefined {
|
||||
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
|
||||
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
|
||||
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
|
||||
const filePath = exec.arguments.file_path.trim()
|
||||
return filePath.length > 0 ? filePath : undefined
|
||||
}
|
||||
|
||||
function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE {
|
||||
return typeof source === 'object' && source !== null
|
||||
&& 'kind' in source && source.kind === 'plugin'
|
||||
&& 'plugin' in source && source.plugin === name
|
||||
}
|
||||
|
||||
function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] {
|
||||
if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return []
|
||||
const changes: WorkspaceInstructionChange[] = []
|
||||
for (const value of meta.changes) {
|
||||
if (!isRecord(value)) continue
|
||||
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
|
||||
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
|
||||
if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue
|
||||
if (value.digest !== undefined && typeof value.digest !== 'string') continue
|
||||
changes.push({
|
||||
action: value.action,
|
||||
scope: value.scope,
|
||||
path: value.path,
|
||||
...value.previousPath !== undefined ? { previousPath: value.previousPath } : {},
|
||||
...value.digest !== undefined ? { digest: value.digest } : {},
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstructionChange): boolean {
|
||||
return a.action === b.action
|
||||
&& a.scope === b.scope
|
||||
&& a.path === b.path
|
||||
&& a.previousPath === b.previousPath
|
||||
&& a.digest === b.digest
|
||||
}
|
||||
|
||||
function visibleInstructionChanges(
|
||||
agent: Agent,
|
||||
pending: Map<string, PendingInstructionChange>,
|
||||
): Map<string, WorkspaceInstructionChange> {
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes)
|
||||
const visible = new Map<string, WorkspaceInstructionChange>()
|
||||
for (const [seq, event] of agent.session.events.entries()) {
|
||||
if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue
|
||||
const changes = workspaceInstructionChanges(event.data.meta)
|
||||
for (const change of changes) {
|
||||
const waiting = pending.get(change.scope)
|
||||
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
|
||||
pending.delete(change.scope)
|
||||
}
|
||||
if (visibleSeqs.has(seq)) visible.set(change.scope, change)
|
||||
}
|
||||
}
|
||||
for (const { change } of pending.values()) visible.set(change.scope, change)
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert retained baseline files into comparison and metadata-cache state.
|
||||
* @param files - baseline files that survived rendering.
|
||||
* @returns latest baseline changes and provider versions keyed by logical scope.
|
||||
*/
|
||||
export function baselineInstructionState(files: LoadedInstructionFile[]): {
|
||||
changes: Map<string, WorkspaceInstructionChange>
|
||||
versions: Map<string, InstructionVersionState>
|
||||
} {
|
||||
const changes = new Map<string, WorkspaceInstructionChange>()
|
||||
const versions = new Map<string, InstructionVersionState>()
|
||||
for (const file of files) {
|
||||
const digest = instructionContentSha1(file.content)
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action: 'set',
|
||||
scope: scopeForDisplayPath(file.displayPath),
|
||||
path: file.displayPath,
|
||||
digest,
|
||||
}
|
||||
changes.set(change.scope, change)
|
||||
if (file.version !== undefined) {
|
||||
versions.set(change.scope, { path: file.displayPath, version: file.version, digest })
|
||||
}
|
||||
}
|
||||
return { changes, versions }
|
||||
}
|
||||
|
||||
function versionStatesFor(session: Session, cache: InstructionVersionCache): Map<string, InstructionVersionState> {
|
||||
let states = cache.get(session)
|
||||
if (states === undefined) {
|
||||
states = new Map()
|
||||
cache.set(session, states)
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only cache updates whose model-visible changes survived final policy.
|
||||
* @param updates - proposed updates from one or more reconciliations.
|
||||
* @param committedChanges - transitions retained on the authoritative result.
|
||||
* @returns updates authorized by an exact retained transition.
|
||||
*/
|
||||
export function retainedInstructionVersionUpdates(
|
||||
updates: readonly InstructionVersionUpdate[],
|
||||
committedChanges: readonly WorkspaceInstructionChange[],
|
||||
): InstructionVersionUpdate[] {
|
||||
return updates.filter(update => committedChanges.some(change => sameInstructionChange(update.change, change)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply authorized metadata-cache transitions without retaining instruction prose.
|
||||
* @param session - owning session.
|
||||
* @param updates - ordered set/delete transitions.
|
||||
* @param cache - session-isolated metadata cache.
|
||||
*/
|
||||
export function applyInstructionVersionUpdates(
|
||||
session: Session,
|
||||
updates: readonly InstructionVersionUpdate[],
|
||||
cache: InstructionVersionCache,
|
||||
): void {
|
||||
if (updates.length === 0) return
|
||||
const states = versionStatesFor(session, cache)
|
||||
for (const update of updates) {
|
||||
if (update.state === undefined) states.delete(update.change.scope)
|
||||
else states.set(update.change.scope, update.state)
|
||||
}
|
||||
if (states.size === 0) cache.delete(session)
|
||||
}
|
||||
|
||||
function pendingChangesFor(
|
||||
session: object,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): Map<string, PendingInstructionChange> {
|
||||
let pending = pendingBySession.get(session)
|
||||
if (pending === undefined) {
|
||||
pending = new Map()
|
||||
pendingBySession.set(session, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function openStep(session: Session): { turn: number; step: number } | undefined {
|
||||
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
|
||||
return boundary?.type === 'step/start' ? boundary.data : undefined
|
||||
}
|
||||
|
||||
function invalidateInstructionVersions(
|
||||
session: Session,
|
||||
scopes: readonly string[],
|
||||
cache: InstructionVersionCache,
|
||||
): void {
|
||||
const states = cache.get(session)
|
||||
if (states === undefined) return
|
||||
for (const scope of scopes) states.delete(scope)
|
||||
if (states.size === 0) cache.delete(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle provisional tool-result state against durable session events.
|
||||
* A matching context event confirms the transition. If its owning step closes
|
||||
* first, the loop discarded its context buffer, so both duplicate suppression
|
||||
* and the metadata fast path must be re-armed for the next successful touch.
|
||||
* @param session - session whose append-only log emitted `event`.
|
||||
* @param event - newly committed session event.
|
||||
* @param pendingBySession - provisional transitions awaiting log confirmation.
|
||||
* @param versionCache - metadata fast path coupled to those transitions.
|
||||
*/
|
||||
export function observeInstructionSessionEvent(
|
||||
session: Session,
|
||||
event: SessionEvent,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
): void {
|
||||
const pending = pendingBySession.get(session)
|
||||
if (pending === undefined) return
|
||||
|
||||
switch (event.type) {
|
||||
case 'context/message': {
|
||||
if (!isWorkspaceContextSource(event.data.source)) return
|
||||
for (const change of workspaceInstructionChanges(event.data.meta)) {
|
||||
const waiting = pending.get(change.scope)
|
||||
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
|
||||
pending.delete(change.scope)
|
||||
}
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(session)
|
||||
return
|
||||
}
|
||||
case 'step/end': {
|
||||
const discardedScopes: string[] = []
|
||||
for (const [scope, waiting] of pending) {
|
||||
const step = waiting.step
|
||||
if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue
|
||||
pending.delete(scope)
|
||||
discardedScopes.push(scope)
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(session)
|
||||
invalidateInstructionVersions(session, discardedScopes, versionCache)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// SessionEventMap is merge-extensible; unrelated events do not settle workspace state.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit only workspace contexts that survived the complete tool pipeline.
|
||||
* The observe-only `tools/result` notification calls this before the loop can
|
||||
* append the returned contexts, closing that short pending window without
|
||||
* trusting an intermediate post-execute decision.
|
||||
* @param agent - session that will receive the final result contexts.
|
||||
* @param contexts - immutable contexts on the authoritative top-level result.
|
||||
* @param pendingBySession - per-session pending transition maps.
|
||||
* @returns transitions committed into the short pending window.
|
||||
*/
|
||||
export function commitPendingInstructionContexts(
|
||||
agent: Agent,
|
||||
contexts: readonly HookContext[] | undefined,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): WorkspaceInstructionChange[] {
|
||||
const committed: WorkspaceInstructionChange[] = []
|
||||
const step = openStep(agent.session)
|
||||
for (const context of contexts ?? []) {
|
||||
if (!isWorkspaceContextSource(context.source)) continue
|
||||
const changes = workspaceInstructionChanges(context.meta)
|
||||
if (changes.length === 0) continue
|
||||
const pending = pendingChangesFor(agent.session, pendingBySession)
|
||||
for (const change of changes) {
|
||||
pending.set(change.scope, {
|
||||
change,
|
||||
afterSeq: agent.session.seq,
|
||||
...step === undefined ? {} : { step },
|
||||
})
|
||||
committed.push(change)
|
||||
}
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back parent-token state when an enclosing tool result discards deferred
|
||||
* contexts. A newer transition for the same scope is left intact.
|
||||
* @param agent - session whose pending state was staged.
|
||||
* @param changes - exact staged transitions to remove when still current.
|
||||
* @param pendingBySession - per-session pending transition maps.
|
||||
*/
|
||||
export function rollbackPendingInstructionChanges(
|
||||
agent: Agent,
|
||||
changes: readonly WorkspaceInstructionChange[],
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): void {
|
||||
const pending = pendingBySession.get(agent.session)
|
||||
if (pending === undefined) return
|
||||
for (const change of changes) {
|
||||
const current = pending.get(change.scope)
|
||||
if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope)
|
||||
}
|
||||
if (pending.size === 0) pendingBySession.delete(agent.session)
|
||||
}
|
||||
|
||||
function relativeScope(projectRoot: string, dir: string): string {
|
||||
const scope = relativeDisplay(projectRoot, dir)
|
||||
return scope.length === 0 ? '.' : scope
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare visible/pending state with provider-visible files and render transitions.
|
||||
* @param agent - session owner whose visible surface supplies durable state.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingBySession - short pending window before returned context is logged.
|
||||
* @param baselineBySession - frozen baseline comparison state per session.
|
||||
* @param versionCache - per-session scope metadata used to skip unchanged reads.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @param options - touched path and whether baseline scopes should be checked.
|
||||
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
|
||||
*/
|
||||
export async function reconcileInstructionContext(
|
||||
agent: Agent,
|
||||
resolved: ResolvedConfig,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
const session = agent.session
|
||||
const pending = pendingChangesFor(session, pendingBySession)
|
||||
const visible = visibleInstructionChanges(agent, pending)
|
||||
const effective = new Map(baselineBySession.get(session) ?? [])
|
||||
for (const [scope, change] of visible) effective.set(scope, change)
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = session.header.cwd ?? process.cwd()
|
||||
// TODO(frozen-project-root): retain the baseline root for the loop instance;
|
||||
// recomputing it after marker edits reinterprets the existing relative scope keys.
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
|
||||
const scopes = new Set<string>()
|
||||
if (options.includeBaselineScopes) {
|
||||
scopes.add('user-global')
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) scopes.add(relativeScope(projectRoot, dir))
|
||||
}
|
||||
for (const scope of effective.keys()) scopes.add(scope)
|
||||
if (options.touchedPath !== undefined) {
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir))
|
||||
}
|
||||
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
const seenAbsolutePaths = new Set<string>()
|
||||
const items: ChangeRenderItem[] = []
|
||||
const versionUpdates: InstructionVersionUpdate[] = []
|
||||
for (const scope of scopes) {
|
||||
const previous = effective.get(scope)
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') continue
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') {
|
||||
versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
|
||||
items.push({
|
||||
change,
|
||||
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
|
||||
})
|
||||
versionUpdates.push({ change })
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
|
||||
seenAbsolutePaths.add(probedFile.absolutePath)
|
||||
const cached = versions.get(scope)
|
||||
if (
|
||||
cached !== undefined
|
||||
&& cached.path === probedFile.displayPath
|
||||
&& cached.version === probedFile.version
|
||||
&& previous !== undefined
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) continue
|
||||
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
|
||||
? previous.path
|
||||
: undefined
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
...previousPath === undefined ? {} : { previousPath },
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
versionUpdates.push({ change, state: nextVersion })
|
||||
}
|
||||
if (items.length === 0) return undefined
|
||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
||||
return {
|
||||
context: workspaceContextHook(rendered.text, rendered.changes),
|
||||
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a successful structured file touch and reconcile its applicable scopes.
|
||||
* @param agent - optional agent attached to the tool execution.
|
||||
* @param exec - completed tool execution descriptor.
|
||||
* @param result - original tool result before post-execute decisions.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param pendingNestedChanges - per-session pending transition maps.
|
||||
* @param baselineInstructionStates - retained baseline comparison state.
|
||||
* @param versionCache - per-session scope metadata used to skip unchanged reads.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
|
||||
*/
|
||||
export async function dynamicInstructionContext(
|
||||
agent: Agent | undefined,
|
||||
exec: ToolExecution,
|
||||
result: ToolExecutionResult,
|
||||
resolved: ResolvedConfig,
|
||||
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||
versionCache: InstructionVersionCache,
|
||||
fileSystem: FileSystem,
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
if (agent === undefined || result.isError) return undefined
|
||||
const touchedPath = filePathFromExecution(exec)
|
||||
if (touchedPath === undefined) return undefined
|
||||
return reconcileInstructionContext(
|
||||
agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem,
|
||||
{
|
||||
touchedPath,
|
||||
includeBaselineScopes: baselineInstructionStates.has(agent.session),
|
||||
...exec.signal === undefined ? {} : { signal: exec.signal },
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const PROBE = 'banana-271828'
|
||||
const NESTED_PROBE = 'papaya-314159'
|
||||
const UPDATED_PROBE = 'guava-161803'
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; agent: Agent }> {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-workspace-context-e2e-'))
|
||||
await mkdir(join(workdir, '.git'), { recursive: true })
|
||||
await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`)
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: 'Answer the user exactly and concisely.' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(WorkspaceContext, { maxBytes: 65536 })
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] })
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('workspace-context-e2e'),
|
||||
sessionId: SessionId('workspace-context-e2e-session'),
|
||||
meta: { cwd: workdir },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
return { ctx, agent: handle.agent }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function finalText(events: SessionEvent[]): string {
|
||||
const message = events.findLast(event => event.type === 'assistant/message')
|
||||
if (message?.type !== 'assistant/message') return ''
|
||||
return message.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real model sees AGENTS.md baseline', () => {
|
||||
it('obeys a probe instruction loaded from the workspace', async () => {
|
||||
const live = await harness()
|
||||
|
||||
live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }])
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
expect(finalText([...live.agent.session.events])).toContain(PROBE)
|
||||
}, 120_000)
|
||||
|
||||
it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => {
|
||||
const live = await harness()
|
||||
await mkdir(join(workdir!, 'pkg/deep'), { recursive: true })
|
||||
await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`)
|
||||
await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n')
|
||||
|
||||
live.agent.send([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }])
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE)
|
||||
}, 120_000)
|
||||
|
||||
it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => {
|
||||
const live = await harness()
|
||||
await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n')
|
||||
live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }])
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`)
|
||||
|
||||
live.agent.send([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }])
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
const events = [...live.agent.session.events]
|
||||
const update = events.find(event => event.type === 'context/message'
|
||||
&& typeof event.data.meta === 'object'
|
||||
&& event.data.meta !== null
|
||||
&& !Array.isArray(event.data.meta)
|
||||
&& event.data.meta.kind === 'workspace-instructions')
|
||||
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
|
||||
changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }],
|
||||
})
|
||||
const updateText = update?.type === 'context/message'
|
||||
? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
: ''
|
||||
expect(updateText).toContain('Updated instructions from: AGENTS.md')
|
||||
expect(finalText(events)).toContain(UPDATED_PROBE)
|
||||
}, 120_000)
|
||||
})
|
||||
2890
packages/context/workspace-context/tests/workspace-context.spec.ts
Normal file
2890
packages/context/workspace-context/tests/workspace-context.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
36
packages/context/workspace-context/tsconfig.json
Normal file
36
packages/context/workspace-context/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user