mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge current master into debug-pangwenjie
# Conflicts: # docs/cordis-catalog/events.md # docs/core-data-structures/core.md # docs/core-data-structures/tools.md # docs/event-producer-consumer.md # docs/rfc/implemented/feature/2026-06-30-interception-seams.md # docs/tool-execution-pipeline.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.md # packages/core/tools/src/index.ts # scripts/gen-doc-graphs.ts
This commit is contained in:
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, both duplicate suppression and the metadata fast path are 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 },
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user