* fix(agent-instructions): budget the user-global and project chains separately The user-global and project instruction files shared one maxBytes and the user-global files were the broadest, so a large project chain omitted them first. Each chain now renders under maxBytes on its own and the results share one system-reminder frame: unchanged output when everything fits or one chain is empty, at most twice maxBytes otherwise. The project chain keeps its omit-broadest-first, truncate-last rule; reconciliation batches follow the same split; the maxBytes key and the baseline identity are unchanged. Tests cover the fast paths, both chains fitting only separately, each chain over its own budget, the 2 × maxBytes boundary at exact and tiny limits, multibyte content, reconciliation batches, and a resumed single-budget baseline receiving the user-global file as one transition. The agent-instructions-budget snapshot is refreshed and agent-instructions-budget-chains is added. README, Config.maxBytes JSDoc, the configuration catalog, a new Agent Note, and an upgrade guide state the per-chain bound. * fix(agent-instructions): keep loaded order in merged frames and note the unshared chain budget A merged frame now lists sections, omissions and truncations in the loaded order, so a reconciliation batch renders its items in one order whether or not it fits a single budget. The guard comment states only the invariant and points to the Agent Note; the intro selection states its rule. The README names the unshared budget between chains. --------- Co-authored-by: ZiyaZhang <[email protected]>
514 lines
23 KiB
TypeScript
514 lines
23 KiB
TypeScript
/**
|
|
* Model-facing workspace instruction rendering within an explicit byte budget.
|
|
*
|
|
* @module @deepseek-ai/dsh-agent-instructions/render
|
|
*/
|
|
|
|
import { basename, dirname, sep } from 'node:path'
|
|
import {
|
|
AGENTS_HOME_ENV,
|
|
DEFAULT_AGENTS_HOME_DISPLAY,
|
|
DEFAULT_DSH_HOME_DISPLAY,
|
|
DSH_HOME_ENV,
|
|
} from '@deepseek-ai/dsh-home-paths'
|
|
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
|
|
|
|
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
|
|
const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
|
|
const AGENT_INSTRUCTIONS_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 REPLACEMENT_AGENT_INSTRUCTIONS_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
|
|
+ AGENT_INSTRUCTIONS_INTRO
|
|
const EMPTY_REPLACEMENT_AGENT_INSTRUCTIONS_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
|
|
+ 'No workspace instructions are currently active.'
|
|
const COMPACT_AGENT_INSTRUCTIONS_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 RenderedAgentInstructions {
|
|
text: string
|
|
omitted: InstructionFile[]
|
|
truncated: TruncatedInstruction[]
|
|
}
|
|
|
|
interface RenderedInstructionContext extends RenderedAgentInstructions {
|
|
/**
|
|
* Original files semantically represented by rendered section text. This is
|
|
* not the complement of `omitted`: a truncated file may be represented here
|
|
* and in `truncated`, while a notice-only file appears in neither. A genuinely
|
|
* empty file counts when its heading survives because that heading conveys
|
|
* that the instruction exists and has no content.
|
|
*/
|
|
represented: LoadedInstructionFile[]
|
|
/** Files whose sections the text holds, in text order, each with the content the text holds. */
|
|
sections: LoadedInstructionFile[]
|
|
/**
|
|
* Intro block of the `<system-reminder>` frame: undefined when the text is
|
|
* empty or a notice without a frame, and the empty string when the frame has
|
|
* no intro, as in a change batch or a merged frame whose chains both rendered
|
|
* without a frame.
|
|
*/
|
|
intro: string | undefined
|
|
}
|
|
|
|
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
|
export interface AgentInstructionChange {
|
|
action: 'set' | 'replace' | 'remove'
|
|
scope: string
|
|
path: string
|
|
digest?: string
|
|
}
|
|
|
|
/** One state transition paired with the content used to render it. */
|
|
export interface ChangeRenderItem {
|
|
change: AgentInstructionChange
|
|
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 {
|
|
const bytes = Buffer.from(value, 'utf8')
|
|
if (bytes.length <= maxBytes) return value
|
|
let end = Math.max(0, Math.trunc(maxBytes))
|
|
// If the first excluded byte is a UTF-8 continuation byte, the budget cut
|
|
// through that code point. Back up to its lead byte and exclude it too.
|
|
while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) {
|
|
end -= 1
|
|
}
|
|
return bytes.subarray(0, end).toString('utf8')
|
|
}
|
|
|
|
function escapeInstructionFrameBody(body: string): string {
|
|
return body.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
|
|
}
|
|
|
|
function sectionText(file: LoadedInstructionFile): string {
|
|
return `Instructions from: ${file.displayPath}\n\n${file.content}`
|
|
}
|
|
|
|
/** Directory component that identifies the harness-home user-global instruction scope. */
|
|
export const USER_GLOBAL_DIRECTORY = 'user-global'
|
|
|
|
/**
|
|
* Directory component that identifies the shared agents-root user-global scope.
|
|
*
|
|
* It is a distinct scope so the harness-home file keeps the scope key that
|
|
* earlier sessions recorded, while {@link instructionCandidateGroup} still
|
|
* deduplicates the two roots as one candidate group.
|
|
*/
|
|
export const AGENTS_GLOBAL_DIRECTORY = 'agents-global'
|
|
|
|
/**
|
|
* Ordered user-global scope directories.
|
|
*
|
|
* Discovery, baseline reconciliation, and deduplication all iterate this order,
|
|
* so the harness home stays the candidate that decides content duplicates
|
|
* against the shared agents root.
|
|
*/
|
|
export const USER_GLOBAL_DIRECTORIES = [USER_GLOBAL_DIRECTORY, AGENTS_GLOBAL_DIRECTORY] as const
|
|
|
|
/**
|
|
* File name of the user-global instruction file under each user-global root.
|
|
* Discovery (`<root>/<name>`) and reconciliation (the user-global scope key's
|
|
* candidate component) both key on this name, so it lives in one place: were the
|
|
* two to disagree, the user-global instruction would load but never reconcile.
|
|
*/
|
|
export const USER_GLOBAL_FILE = 'AGENTS.md'
|
|
|
|
/**
|
|
* Derive the logical instruction scope from a model-facing path.
|
|
* @param displayPath - project-relative or user-global instruction path.
|
|
* @returns `user-global`, `agents-global`, `.`, or the containing project-relative directory.
|
|
*/
|
|
export function scopeForDisplayPath(displayPath: string): string {
|
|
if (displayPath === `${DEFAULT_DSH_HOME_DISPLAY}/${USER_GLOBAL_FILE}`
|
|
|| displayPath === `$${DSH_HOME_ENV}/${USER_GLOBAL_FILE}`) return USER_GLOBAL_DIRECTORY
|
|
if (displayPath === `${DEFAULT_AGENTS_HOME_DISPLAY}/${USER_GLOBAL_FILE}`
|
|
|| displayPath === `$${AGENTS_HOME_ENV}/${USER_GLOBAL_FILE}`) return AGENTS_GLOBAL_DIRECTORY
|
|
return dirname(displayPath)
|
|
}
|
|
|
|
/**
|
|
* Whether a decoded scope directory is one of the two user-global roots.
|
|
* @param directory - `user-global`, `agents-global`, `.`, or a project-relative directory.
|
|
* @returns true for the harness-home and shared agents-root scopes.
|
|
*/
|
|
export function isUserGlobalDirectory(directory: string): boolean {
|
|
return directory === USER_GLOBAL_DIRECTORY || directory === AGENTS_GLOBAL_DIRECTORY
|
|
}
|
|
|
|
/**
|
|
* Candidate group of one decoded scope directory.
|
|
*
|
|
* The harness home and the shared agents root are the ordered candidates of one
|
|
* user-global candidate group: identical trimmed content renders once, and an
|
|
* unobservable candidate preserves the last-good state of its group. Every
|
|
* project directory forms its own group.
|
|
* @param directory - `user-global`, `agents-global`, `.`, or a project-relative directory.
|
|
* @returns the shared group key of both user-global roots, or the directory itself.
|
|
*/
|
|
export function instructionCandidateGroup(directory: string): string {
|
|
return directory === AGENTS_GLOBAL_DIRECTORY ? USER_GLOBAL_DIRECTORY : directory
|
|
}
|
|
|
|
/** Directory names that {@link scopeForDisplayPath} reads as a user-global scope, not a project path. */
|
|
const RESERVED_SCOPE_DIRECTORIES: ReadonlySet<string> = new Set(USER_GLOBAL_DIRECTORIES)
|
|
|
|
/**
|
|
* Keep a project-relative path out of the user-global scope namespace.
|
|
*
|
|
* A project directory whose name equals a user-global scope directory would
|
|
* otherwise resolve to the global root for probing and joins the global
|
|
* candidate group for deduplication. Escaping the collision with a leading `.`
|
|
* keeps the file in its own project scope.
|
|
* @param relativePath - project-root-relative path using the platform separator.
|
|
* @returns the path, with a leading `.` component when its first component collides.
|
|
*/
|
|
export function escapeProjectDisplayPath(relativePath: string): string {
|
|
const separatorIndex = relativePath.search(/[\\/]/)
|
|
const first = separatorIndex < 0 ? relativePath : relativePath.slice(0, separatorIndex)
|
|
return RESERVED_SCOPE_DIRECTORIES.has(first) ? `.${sep}${relativePath}` : relativePath
|
|
}
|
|
|
|
const SCOPE_SEPARATOR = '\u0000'
|
|
|
|
/**
|
|
* Compose the reconciliation key for one instruction candidate file.
|
|
* Each loaded candidate is tracked independently, so the key pairs the logical
|
|
* directory with the exact candidate file name behind a NUL separator that no
|
|
* directory path or file name can contain. Distinct candidates in one directory
|
|
* (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
|
|
* never collide in the scope-keyed state maps.
|
|
* @param directory - `user-global`, `agents-global`, `.`, or a project-relative directory.
|
|
* @param candidateName - instruction file name within that directory.
|
|
* @returns the per-candidate logical scope key.
|
|
*/
|
|
export function candidateScopeKey(directory: string, candidateName: string): string {
|
|
return `${directory}${SCOPE_SEPARATOR}${candidateName}`
|
|
}
|
|
|
|
/**
|
|
* Derive the per-candidate scope key for a loaded instruction file.
|
|
* @param displayPath - project-relative or user-global instruction path.
|
|
* @returns the scope key pairing the file's directory with its name.
|
|
*/
|
|
export function instructionScopeKey(displayPath: string): string {
|
|
return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath))
|
|
}
|
|
|
|
/**
|
|
* Recover the directory and candidate name that {@link candidateScopeKey} encoded.
|
|
* @param scope - a per-candidate scope key.
|
|
* @returns the directory scope and the candidate file name within it.
|
|
*/
|
|
export function decodeScopeKey(scope: string): { directory: string; candidateName: string } {
|
|
const separator = scope.indexOf(SCOPE_SEPARATOR)
|
|
/* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
|
|
if (separator < 0) return { directory: scope, candidateName: '' }
|
|
return { directory: scope.slice(0, separator), candidateName: scope.slice(separator + 1) }
|
|
}
|
|
|
|
const ADDITIONAL_INSTRUCTIONS_GUIDANCE = 'Use them as guidance when relevant; more specific instructions take precedence. '
|
|
+ 'They do not override system, developer, or direct user instructions.'
|
|
|
|
function additionalSectionText(file: LoadedInstructionFile): string {
|
|
const scope = scopeForDisplayPath(file.displayPath)
|
|
// A user-global scope directory is an internal reconciliation key, so it must
|
|
// never reach this model-visible sentence.
|
|
const applicability = isUserGlobalDirectory(scope)
|
|
? 'These user-global instructions apply to all work.'
|
|
: `These instructions apply to work under \`${scope}\`.`
|
|
return [
|
|
`Additional instructions from: ${file.displayPath}`,
|
|
'',
|
|
`${applicability} ${ADDITIONAL_INSTRUCTIONS_GUIDANCE}`,
|
|
'',
|
|
file.content,
|
|
].join('\n')
|
|
}
|
|
|
|
const BASELINE_RENDER_STYLE: RenderStyle = { intro: AGENT_INSTRUCTIONS_INTRO, section: sectionText }
|
|
|
|
function baselineRenderStyle(files: LoadedInstructionFile[], replacePreviousBaseline: boolean | undefined): RenderStyle {
|
|
if (replacePreviousBaseline !== true) return BASELINE_RENDER_STYLE
|
|
return {
|
|
...BASELINE_RENDER_STYLE,
|
|
intro: files.length === 0
|
|
? EMPTY_REPLACEMENT_AGENT_INSTRUCTIONS_INTRO
|
|
: REPLACEMENT_AGENT_INSTRUCTIONS_INTRO,
|
|
}
|
|
}
|
|
|
|
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.`
|
|
}
|
|
return [
|
|
`Updated instructions from: ${change.path}`,
|
|
'',
|
|
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
|
|
'',
|
|
file.content,
|
|
].join('\n')
|
|
}
|
|
|
|
/**
|
|
* Render one reconciliation batch and retain only transitions that fit.
|
|
* User-global and project transitions are budgeted as separate chains, so a
|
|
* batch holding both can exceed `maxBytes` and is at most twice that size.
|
|
* @param items - ordered state transitions and current file contents.
|
|
* @param maxBytes - maximum UTF-8 bytes for each instruction chain 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: AgentInstructionChange[] } {
|
|
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 = renderInstructionChains(items.map(item => item.file), maxBytes, style)
|
|
const represented = new Set(rendered.represented.map(file => file.absolutePath))
|
|
return {
|
|
text: rendered.text,
|
|
changes: items
|
|
.filter(item => represented.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)
|
|
// This producer owns the complete <system-reminder> frame. Session adds no
|
|
// wrapper; see Derived history in packages/core/session/README.md.
|
|
return [SYSTEM_REMINDER_OPEN, escapeInstructionFrameBody(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 isDisabledBudget(maxBytes: number): boolean {
|
|
return maxBytes <= 0 || !Number.isFinite(maxBytes)
|
|
}
|
|
|
|
function renderInstructionContext(
|
|
files: LoadedInstructionFile[],
|
|
maxBytes: number,
|
|
style: RenderStyle,
|
|
): RenderedInstructionContext {
|
|
if (isDisabledBudget(maxBytes)) {
|
|
return { text: '', omitted: files, truncated: [], represented: [], sections: [], intro: undefined }
|
|
}
|
|
|
|
const fullText = buildInstructionText(files, maxBytes, [], [], style)
|
|
if (byteLength(fullText) <= maxBytes) {
|
|
return { text: fullText, omitted: [], truncated: [], represented: files, sections: files, intro: style.intro }
|
|
}
|
|
|
|
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: [], represented: included, sections: included, intro: style.intro }
|
|
}
|
|
}
|
|
|
|
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: [], represented: [], sections: [], intro: undefined }
|
|
const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
|
const originalBytes = byteLength(mostSpecific.content)
|
|
|
|
for (const candidateStyle of [style, { ...style, intro: COMPACT_AGENT_INSTRUCTIONS_INTRO }]) {
|
|
const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle)
|
|
const includedBytes = byteLength(truncatedFile.content)
|
|
const truncated = [{
|
|
displayPath: mostSpecific.displayPath,
|
|
originalBytes,
|
|
includedBytes,
|
|
}]
|
|
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
|
|
if (byteLength(text) <= maxBytes) {
|
|
const represented = includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : []
|
|
return { text, omitted, truncated, represented, sections: [truncatedFile], intro: candidateStyle.intro }
|
|
}
|
|
}
|
|
|
|
const truncated = [{
|
|
displayPath: mostSpecific.displayPath,
|
|
originalBytes,
|
|
includedBytes: 0,
|
|
}]
|
|
const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated))
|
|
const heading = withTruncatedContent(mostSpecific, 0)
|
|
const compactWithHeading = escapeInstructionFrameBody([compactNotice, style.section(heading)].join('\n\n'))
|
|
if (byteLength(compactWithHeading) <= maxBytes) {
|
|
const represented = originalBytes === 0 ? [mostSpecific] : []
|
|
return { text: compactWithHeading, omitted, truncated, represented, sections: [heading], intro: undefined }
|
|
}
|
|
const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
|
|
return { text, omitted, truncated, represented: [], sections: [], intro: undefined }
|
|
}
|
|
|
|
function isUserGlobalInstruction(file: InstructionFile): boolean {
|
|
return isUserGlobalDirectory(scopeForDisplayPath(file.displayPath))
|
|
}
|
|
|
|
/**
|
|
* Render the user-global chain and the project chain under one `maxBytes` each.
|
|
*
|
|
* A disabled budget, files from only one chain, or files that fit within
|
|
* `maxBytes` together return the single-budget rendering unchanged. Otherwise
|
|
* each chain renders alone (omit the broadest files, truncate the last file,
|
|
* then fall back to the compact intro, to the marker with an empty heading,
|
|
* and to the marker alone), and one frame holds the merged marker when a chain
|
|
* omitted or truncated a file, one intro when a chain kept its frame, and the
|
|
* sections of both chains as rendered, in their loaded order, which puts
|
|
* user-global files first in a baseline. The single-budget rendering replaces a
|
|
* merged frame longer than twice `maxBytes`.
|
|
*/
|
|
function renderInstructionChains(
|
|
files: LoadedInstructionFile[],
|
|
maxBytes: number,
|
|
style: RenderStyle,
|
|
): RenderedInstructionContext {
|
|
const whole = renderInstructionContext(files, maxBytes, style)
|
|
const userGlobal = files.filter(isUserGlobalInstruction)
|
|
const project = files.filter(file => !isUserGlobalInstruction(file))
|
|
if (isDisabledBudget(maxBytes) || userGlobal.length === 0 || project.length === 0
|
|
|| (whole.omitted.length === 0 && whole.truncated.length === 0)) return whole
|
|
|
|
const chains = [
|
|
renderInstructionContext(userGlobal, maxBytes, style),
|
|
renderInstructionContext(project, maxBytes, style),
|
|
]
|
|
// Merged records and sections keep the loaded order, so a reconciliation batch
|
|
// renders its items in one order whether or not it fits a single budget.
|
|
const inLoadedOrder = <T extends InstructionFile>(records: T[]): T[] => {
|
|
const byPath = new Map(records.map(record => [record.absolutePath, record]))
|
|
return files.flatMap((file) => {
|
|
const record = byPath.get(file.absolutePath)
|
|
return record === undefined ? [] : [record]
|
|
})
|
|
}
|
|
const omitted = inLoadedOrder(chains.flatMap(chain => chain.omitted))
|
|
const sections = inLoadedOrder(chains.flatMap(chain => chain.sections))
|
|
const represented = inLoadedOrder(chains.flatMap(chain => chain.represented))
|
|
// A chain truncates only its last file, so the chain whose last file loads
|
|
// first lists its truncation first.
|
|
const userGlobalLast = files.findLastIndex(isUserGlobalInstruction)
|
|
const projectLast = files.findLastIndex(file => !isUserGlobalInstruction(file))
|
|
const truncated = (userGlobalLast < projectLast ? chains : chains.toReversed()).flatMap(chain => chain.truncated)
|
|
const intros = chains.flatMap(chain => chain.intro === undefined ? [] : [chain.intro])
|
|
// A chain that kept the full frame decides the intro; otherwise the first
|
|
// surviving (compact) intro applies; otherwise there is none.
|
|
const intro = intros.includes(style.intro) ? style.intro : intros[0] ?? ''
|
|
const text = buildInstructionText(sections, maxBytes, omitted, truncated, { ...style, intro })
|
|
// Each chain text fits maxBytes; the merged frame exceeds 2 * maxBytes only by the join beside a
|
|
// frameless chain or by a chain marker cut to fit, and the single-budget rendering then replaces it
|
|
// (.agents/notes/implemented/architecture/2026-10-16-separate-instruction-chain-budgets.md).
|
|
if (byteLength(text) > 2 * maxBytes) return whole
|
|
return { text, omitted, truncated, represented, sections, intro }
|
|
}
|
|
|
|
/**
|
|
* Render a baseline together with the exact source files semantically represented in it.
|
|
* @param files - loaded files ordered from broadest to most specific.
|
|
* @param options - per-chain rendering byte budget and whether this baseline supersedes a visible predecessor.
|
|
* @returns bounded public rendering plus files with surviving content, including genuinely empty files.
|
|
* @internal
|
|
*/
|
|
export function renderAgentInstructionSet(
|
|
files: LoadedInstructionFile[],
|
|
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
|
): { rendered: RenderedAgentInstructions; included: LoadedInstructionFile[] } {
|
|
const style = baselineRenderStyle(files, options.replacePreviousBaseline)
|
|
const { text, omitted, truncated, represented } = renderInstructionChains(files, options.maxBytes, style)
|
|
return { rendered: { text, omitted, truncated }, included: represented }
|
|
}
|
|
|
|
/**
|
|
* Render the baseline instruction chains with deterministic precedence budgeting.
|
|
* The user-global and project chains are each bounded by `maxBytes`, so a
|
|
* baseline holding both can exceed `maxBytes` and is at most twice that size.
|
|
* @param files - loaded files ordered from broadest to most specific.
|
|
* @param options - per-chain rendering byte budget and whether this baseline supersedes a visible predecessor.
|
|
* @returns bounded baseline prompt text and budget diagnostics.
|
|
*/
|
|
export function renderAgentInstructions(
|
|
files: LoadedInstructionFile[],
|
|
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
|
): RenderedAgentInstructions {
|
|
return renderAgentInstructionSet(files, options).rendered
|
|
}
|