diff --git a/changelog.d/tool-presentation.added.md b/changelog.d/tool-presentation.added.md new file mode 100644 index 00000000..bab93362 --- /dev/null +++ b/changelog.d/tool-presentation.added.md @@ -0,0 +1,4 @@ +- Add opt-in resident-editable tool descriptions and visibility, a source-grouped generated catalogue, and optional component description profiles. Hidden tools retain their existing execution permissions. +- Keep hidden definitions available to compression, protect catalogue path aliases and discovery instructions, and preserve shared override-file permissions during resident edits. + +- Live requests capture advertised and compression definitions together before asynchronous context gathering, so mid-gather tool refreshes cannot split their snapshots. Settings edits preserve permissions through the open temporary-file descriptor and reject detected pathname replacement before committing. diff --git a/docs/tool-presentation.md b/docs/tool-presentation.md new file mode 100644 index 00000000..e37a37e0 --- /dev/null +++ b/docs/tool-presentation.md @@ -0,0 +1,31 @@ +# Opt-in tool presentation + +Configure an agent with `toolPresentation: {path: "/absolute/path/tools.json", cataloguePath: "board/tool-catalogue.md"}`. Workspace is required; the catalogue mount must exist. This adds exactly `set_tool_visibility(name, visible)` and `set_tool_description(name, description)`. A null description removes its override. Tool names, schemas, permissions and dispatch remain unchanged. + +The file is the sole persistent source for both tool edits and ordinary filesystem edits: + +```json +{"version":1,"tools":{"mcpl--shell--runCommand":{"visible":true,"description":"Local shell guidance"}}} +``` + +Removing an entry restores its defaults. Unknown/unavailable names are retained with diagnostics, not activated. Missing files mean defaults. Malformed files cause all overrides to be ignored, with a diagnostic in inspection and the catalogue; editing tools refuse to overwrite malformed data. Read limits are 256 KiB per file and 32,768 characters per description. Tool edits serialize with a sibling `.lock`, check for intervening changes, preserve permission bits, and rename a temporary file. Arbitrary external editors do not participate in the lock: coordinate simultaneous editing; the revision check is not an operating-system transaction against uncooperative writers. A stale lock after process termination requires operator removal after checking no writer is active. Symlink targets must be edited directly; editing tools reject replacing symlinks. + +`workspace--read {"path":"board/tool-catalogue.md","limit":140}` reads a generated, read-only virtual file, never a stale Chronicle blob. It lists available native definitions, including hidden ones, schemas, original/effective descriptions and a revision. It is not a physical disk file and does not yet appear in directory/glob results or the host's ordinary file-download endpoint. Its exact path is always signposted in both editing tools, even if their descriptions are overridden. Reserve an otherwise unused path. This does not expand `utils` into its internal module operations. + +The two editing tools and `workspace--read` cannot be hidden. Permission to use them is required at setup, not granted by this feature. Distinct agents need distinct catalogue paths; no implicit inheritance into ephemeral or subconscious agents is added. Sharing an override file is explicit configuration, independent of who edits it. + +Changes affect the next newly compiled request. They do not rewrite an in-flight Membrane stream or old conversation messages containing tool descriptions. Hidden tools remain in execution surfaces; visibility is not access control. Preview, inference and RFC-008 tool listings share advertised-definition assembly. Context compression and maintenance retain the full permission-eligible definition set, including hidden tools, because historical messages may still contain their calls. `inspectToolPresentation` returns current state; `getRequestToolPresentation(previewRequest)` returns the matching frozen preview metadata. + +Framework unit/integration tests exercise persistent edits, reset, malformed input, direct file edits, isolation, preview matching, both dispatch routes, catalogue reads and recovery protection. Provider transport and permission enforcement remain in their existing owners. + +The catalogue begins with exact names and visibility grouped by registered module or MCPL server, followed by full definitions. Index entries give offset/limit values for reading a definition. Those line references apply to the displayed revision; reread the index after changes. Sources come from the registries, including configured MCPL prefixes. Groups are rebuilt each read rather than maintained as a fixed taxonomy. Visible/hidden is not an imposed primary/secondary ranking. + +## Component description defaults + +Optional `toolPresentation.defaults` is an array of `{source, path}` profiles, with absolute file paths and unique registered source labels (for example `Module: workspace` or `MCPL server: discord`). Each profile uses `{version:1,tools:{"exact-tool-name":{description:"..."}}}`. Profiles may supply descriptions only; visibility stays in the resident file. Binding uses the tool registry's source attribution, not a guessed name prefix. A missing component contributes nothing, including no missing-profile error. Unknown tool entries never create tools. + +Precedence: installed description → selected component profile → resident description. Setting a resident description to null removes that override and reveals the selected default. Existing configurations without profiles retain their behavior. Malformed active profiles produce diagnostics and fall back to installed wording; resident overrides still apply. Parameter descriptions are not overridden by these files. Snapshot entries expose descriptionSource, and the generated catalogue includes it. + +Profiles are explicitly selected by deployment configuration in this prototype; packages are not automatically discovered. Shared wording can ultimately move upstream into each component. Local profiles let deployments try wording independently while preserving ordinary resident files. + +Catalogue access compares normalized mount-resolved paths, including dot segments and alternate mounts pointing at the same location. Aliases do not bypass generated reads, ownership or mutation protection. Editing restores the original file mode after temporary-file creation, so the process umask does not silently remove shared write permissions. Component-default descriptions, like resident overrides, cannot remove the editing tools’ catalogue signposts. diff --git a/src/agent.ts b/src/agent.ts index ae9d38a0..7454f2d8 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -783,16 +783,18 @@ export class Agent { async buildActivationRequest( availableTools: ToolDefinition[], injections?: ContextInjection[], - budget?: TokenBudget + budget?: TokenBudget, + compressionTools: ToolDefinition[] = availableTools ): Promise { - // Keep the context manager's view of the live tool surface current: the + // Compression may revisit hidden tools in recorded history. Keep its full + // definition set separate from the resident's advertised tools: the // autobiographical strategy must declare the same tools on its // summarizer/compression requests, or transcripts containing tool blocks // are refused by Anthropic's reasoning_extraction classifier (labclaude // incident, 2026-07-09). Optional chaining: older context-manager // versions don't have the hook. (this.contextManager as unknown as { setToolDefinitions?: (t: ToolDefinition[]) => void }) - .setToolDefinitions?.(availableTools); + .setToolDefinitions?.(compressionTools); const strategy = (this.contextManager as unknown as { getStrategy?: () => unknown }) .getStrategy?.() as { @@ -873,7 +875,8 @@ export class Agent { async startStreamWithInjections( availableTools: ToolDefinition[], injections?: ContextInjection[], - budget?: TokenBudget + budget?: TokenBudget, + compressionTools: ToolDefinition[] = availableTools ): Promise { if (this._state.status !== 'idle') { throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`); @@ -908,7 +911,7 @@ export class Agent { this.lastStreamRealInputTokens = 0; this.lastStreamOutputTokens = 0; - const request = await this.buildActivationRequest(availableTools, injections, budget); + const request = await this.buildActivationRequest(availableTools, injections, budget, compressionTools); request.messages = this.toolResultGuard.prepareRequest(request.messages, true); const receiptAware = (this.contextManager as unknown as { getStrategy?: () => unknown }) diff --git a/src/framework.ts b/src/framework.ts index 25cdb419..519e3042 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1,3 +1,4 @@ +import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } from "./tool-presentation.js"; import { dirname, join } from 'node:path'; import { INLINE_WITHHELD_TEXT, classifyBlock, isInlineContradiction, referenceRegistry, referenceStubOrNull } from './mcpl/references.js'; import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js'; @@ -949,6 +950,12 @@ function truncateReason(reason: string, max = 160): string { } export class AgentFramework { + private toolPresentations = new Map(); + private presentationPreviews = new WeakMap(); + getRequestToolPresentation(request: object): PresentationSnapshot | null { + return this.presentationPreviews.get(request) ?? null; + } + private store: JsStore; private ownsStore: boolean; private membrane: Membrane; @@ -1577,6 +1584,7 @@ export class AgentFramework { // Create agents for (const agentConfig of config.agents) { await framework.createAgent(agentConfig); + if (agentConfig.toolPresentation) framework.toolPresentations.set(agentConfig.name, new ToolPresentation(agentConfig.toolPresentation)); } // The subconscious resident (issue #77) registers after the residents so @@ -1594,6 +1602,17 @@ export class AgentFramework { for (const module of config.modules) { await framework.addModule(module); } + for (const [agentName, presentation] of framework.toolPresentations) { + const workspace = framework.getWorkspaceModule(); + if (!workspace) throw new Error('Tool presentation requires workspace'); + const agent = framework.agents.get(agentName)!; + for (const name of ['workspace--read', 'set_tool_visibility', 'set_tool_description']) { + if (!agent.canUseTool(name)) throw new Error(`Tool presentation recovery requires ${name}`); + } + workspace.registerGeneratedTextFile(presentation.config.cataloguePath, + () => renderCatalogue(framework.inspectToolPresentation(agentName)!), agentName); + } + // Initialize per-channel conversation routing (if configured) if (config.conversations) { @@ -2200,7 +2219,7 @@ export class AgentFramework { console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error); } const cm = agent.getContextManager(); - const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name)); + const tools = this.compressionToolsForAgent(agent.name); cm.setToolDefinitions(tools); if (this.providerGateBlocked(agent.name)) return []; if (cm.isReady()) return []; @@ -2715,7 +2734,7 @@ export class AgentFramework { : t); return [...SUBCONSCIOUS_TOOLS, ...basics]; } - return this.getAllTools().map((tool) => { + return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])].map((tool) => { if (tool.name === 'think') { return this.buildThinkTool( snapshot?.sameRoundThinkTextPolicy @@ -2726,6 +2745,53 @@ export class AgentFramework { }); } + /** Available is independent of presentation visibility; execution callers keep this surface. */ + private availableToolsForPresentation(agentName: string, snapshot?: InferenceToolSnapshot) { + const agent = this.agents.get(agentName); + if (!agent) throw new Error(`Unknown agent: ${agentName}`); + const tools = this.getToolsForAgent(agentName, snapshot).filter(t => agent.canUseTool(t.name)); + if (agent.proseRouting === 'explicit' && agent.canUseTool(PROSE_HELP_TOOL.name)) tools.push(PROSE_HELP_TOOL); + return tools; + } + + inspectToolPresentation(agentName: string, snapshot?: InferenceToolSnapshot): PresentationSnapshot | null { + const presentation = this.toolPresentations.get(agentName); + if (!presentation) return null; + const tools = this.availableToolsForPresentation(agentName, snapshot); + const sources = new Map(); + for (const tool of this.moduleRegistry.getAllTools()) { + sources.set(tool.name, `Module: ${tool.name.split('--')[0]}`); + } + // Use registered server prefixes, not a guess from a conventional mcpl-- name. + for (const tool of tools) { + for (const config of this.mcplServerConfigs.values()) { + if (tool.name.startsWith((config.toolPrefix ?? `mcpl--${config.id}`) + '--')) { + sources.set(tool.name, `MCPL server: ${config.id}`); break; + } + } + if (!sources.has(tool.name)) sources.set(tool.name, 'Framework'); + } + return presentation.resolve(tools, sources); + } + + private advertisedToolsForAgent(agentName: string, snapshot?: InferenceToolSnapshot) { + return this.inspectToolPresentation(agentName, snapshot)?.advertised + ?? this.availableToolsForPresentation(agentName, snapshot); + } + + /** Compression can revisit calls to hidden tools; never apply visibility here. */ + private compressionToolsForAgent(agentName: string, snapshot?: InferenceToolSnapshot) { + return this.inspectToolPresentation(agentName, snapshot)?.available + ?? this.availableToolsForPresentation(agentName, snapshot); + } + + private editToolPresentation(agentName: string, call: ToolCall): ToolResult { + const presentation = this.toolPresentations.get(agentName); + const agent = this.agents.get(agentName); + if (!presentation || !agent?.canUseTool(call.name)) return {success:false,isError:true,error:'Tool presentation is not enabled for this caller'}; + return presentation.edit(call.name, call.input, this.availableToolsForPresentation(agentName)); + } + /** * The tools one agent is shown at inference: its surface (the subconscious * has its own), less what its permissions deny, plus — for explicit-mode @@ -2737,9 +2803,7 @@ export class AgentFramework { agent: Agent, snapshot?: InferenceToolSnapshot, ): import('./types/index.js').ToolDefinition[] { - const tools = this.getToolsForAgent(agent.name, snapshot).filter((t) => agent.canUseTool(t.name)); - if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL); - return tools; + return this.advertisedToolsForAgent(agent.name, snapshot); } getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot { @@ -3558,7 +3622,12 @@ export class AgentFramework { throw new Error(`Agent not found: ${agentName}`); } - const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name)); + const presentation = this.inspectToolPresentation(agentName); + const tools = presentation?.advertised ?? this.availableToolsForPresentation(agentName); + const capture = (request: NormalizedRequest) => { + if (presentation) this.presentationPreviews.set(request, presentation); + return request; + }; // An explicit budget compiles against a HYPOTHETICAL window instead of the // agent's live one. That also suppresses transition-settling in @@ -3567,7 +3636,7 @@ export class AgentFramework { // Default: no dynamic injection gathering → fully transparent (no // inference, no Chronicle writes, no external RPC). Opt in explicitly. if (!opts?.injections) { - return agent.buildActivationRequest(tools, undefined, opts?.budget); + return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools)); } // Full-fidelity path: mirrors startAgentStream's injection gathering. @@ -3602,7 +3671,7 @@ export class AgentFramework { } } - return agent.buildActivationRequest(tools, injections, opts?.budget); + return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools)); } /** @@ -9057,7 +9126,11 @@ export class AgentFramework { try { const requestSnapshot = this.captureInferenceToolSnapshot(agent); - const tools = this.agentToolSurface(agent, requestSnapshot); + // Capture both surfaces together before context hooks can refresh tools. + const presentation = this.inspectToolPresentation(agent.name, requestSnapshot); + const compressionTools = presentation?.available + ?? structuredClone(this.availableToolsForPresentation(agent.name, requestSnapshot)); + const tools = presentation?.advertised ?? compressionTools; // Gather context from modules (pull-based) and MCPL hooks (push-based) // Both produce ContextInjection[] that get merged before inference. @@ -9129,7 +9202,7 @@ export class AgentFramework { request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, - } = await agent.startStreamWithInjections(tools, injections); + } = await agent.startStreamWithInjections(tools, injections, undefined, compressionTools); if (this.agents.get(agent.name) !== agent) { stream.cancel(); agent.cancelStream(); @@ -10893,6 +10966,7 @@ export class AgentFramework { } private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise { + if (isPresentationTool(call.name)) return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call); // Client-side programmatic tool calling for promise-based callers // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral // gets its own interpreter state. @@ -11967,6 +12041,12 @@ export class AgentFramework { private dispatchToolCall(agentName: string, call: ToolCall): void { // Enrich call with caller identity so modules can resolve the calling agent const enrichedCall: ToolCall = { ...call, callerAgentName: agentName }; + if (isPresentationTool(call.name)) { + const result = this.editToolPresentation(agentName, enrichedCall); + this.pushEvent({type:'tool-result',callId:call.id,agentName,moduleName:'tool-presentation',result}); + return; + } + // Route MCPL tool calls to the appropriate server via prefix map const mcplMatch = this.resolveMcplTool(enrichedCall.name); @@ -12134,7 +12214,7 @@ export class AgentFramework { const startTime = Date.now(); this.moduleRegistry - .handleToolCall(call) + .handleToolCall({ ...call, callerAgentName: agentName }) .then((result) => { const durationMs = Date.now() - startTime; this.emitTrace({ diff --git a/src/index.ts b/src/index.ts index ce562ae5..b198bcee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,3 +146,5 @@ export type { OfflineRecoveryBranchOptions, OfflineRecoveryBranchResult, } from './recovery/offline-branch.js'; + +export * from "./tool-presentation.js"; diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 3b90f5d3..9091ef1f 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1075,9 +1075,48 @@ export class WorkspaceModule implements Module { // Tool Dispatch // ========================================================================== + private generatedTextFiles = new Map string; agentName: string}>(); + + /** Compare the same normalized physical path for registration and tool access. */ + private generatedFileKey(path: string): string { + const slash = path.indexOf('/'); + const mountName = slash < 0 ? path : path.slice(0, slash); + const mount = this.config.mounts.find(m => m.name === mountName); + if (!mount) throw new Error(`Unknown mount: "${mountName}"`); + const resolved = resolve(mount.path, slash < 0 ? '' : path.slice(slash + 1)); + if (!isContainedPath(mount.path, resolved)) throw new Error(`Path traversal detected: "${path}"`); + return resolved; + } + + private findGeneratedTextFile(path: string) { + if (!this.generatedTextFiles.size) return undefined; + try { return this.generatedTextFiles.get(this.generatedFileKey(path)); } + catch { + // Not an alias of a valid generated file. Let the selected tool return + // its established validation error (including image-specific errors). + return undefined; + } + } + + /** Generated files bypass stored blobs so every read reflects current definitions. */ + registerGeneratedTextFile(path: string, read: () => string, agentName: string): void { + const [mount, ...parts] = path.split('/'); + if (!this.config.mounts.some(m => m.name === mount) || !parts.length + || parts.some(p => !p || p === '.' || p === '..') || this.generatedTextFiles.has(this.generatedFileKey(path))) + throw new Error(`Duplicate/invalid generated path: ${path}`); + this.generatedTextFiles.set(this.generatedFileKey(path), {read, agentName}); + } + async handleToolCall(call: ToolCall): Promise { try { const input = call.input as Record; + const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined; + if (generated) { + if (call.callerAgentName !== generated.agentName) return {success:false,isError:true,error:'Generated file belongs to another agent'}; + if (call.name !== 'read') return {success:false,isError:true,error:'Generated file is read-only; edit the presentation configuration instead'}; + return await this.handleRead(input as unknown as ReadInput, generated.read()); + } + switch (call.name) { case 'read': return await this.handleRead(input as unknown as ReadInput); case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput); @@ -1746,7 +1785,7 @@ export class WorkspaceModule implements Module { // Tool Handlers // ========================================================================== - private async handleRead(input: ReadInput): Promise { + private async handleRead(input: ReadInput, generatedContent?: string): Promise { const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined; const offsetChars = input.offsetChars ?? 0; const limitChars = input.limitChars ?? 2000; @@ -1760,6 +1799,8 @@ export class WorkspaceModule implements Module { return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' }; } } + let content = generatedContent; + if (content === undefined) { const { mount, relativePath } = this.parsePath(input.path); const store = this.getStore(); @@ -1775,7 +1816,8 @@ export class WorkspaceModule implements Module { return { success: false, error: `Blob not found for: ${input.path}`, isError: true }; } - const content = blob.toString('utf-8'); + content = blob.toString('utf-8'); + } if (characterPaging) { const start = Math.min(offsetChars, content.length); const splitsPair = (at: number): boolean => diff --git a/src/tool-presentation.ts b/src/tool-presentation.ts new file mode 100644 index 00000000..8fd9eef4 --- /dev/null +++ b/src/tool-presentation.ts @@ -0,0 +1,188 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { fchmodSync, closeSync, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import type { ToolDefinition, ToolResult } from './types/events.js'; + +export interface ToolPresentationConfig { + /** Absolute path to shared editable JSON. Missing file means no overrides. */ + path: string; + /** Reserved generated workspace path, e.g. board/tool-catalogue.md. */ + cataloguePath: string; + /** Optional description-only profiles, bound to a registered component source. */ + defaults?: { source: string; path: string }[]; +} +export interface ToolOverride { description?: string; visible?: boolean } +export interface PresentationDocument { version: 1; tools: Record } +export interface PresentationSnapshot { + revision: string; + source: string; + cataloguePath: string; + diagnostics: string[]; + available: ToolDefinition[]; + advertised: ToolDefinition[]; + entries: { name: string; source: string; visible: boolean; originalDescription: string; description: string; descriptionSource?: string; inputSchema: ToolDefinition['inputSchema'] }[]; +} +const MAX_BYTES = 256 * 1024; +const protectedNames = new Set(['set_tool_visibility', 'set_tool_description', 'workspace--read']); +export const isPresentationTool = (name: string): boolean => name === 'set_tool_visibility' || name === 'set_tool_description'; +const hash = (value: string): string => createHash('sha256').update(value).digest('hex'); + +export function parsePresentation(raw: string): PresentationDocument { + const doc = JSON.parse(raw); + if (!doc || doc.version !== 1 || !doc.tools || typeof doc.tools !== 'object' || Array.isArray(doc.tools) + || Object.keys(doc).some(k => !['version', 'tools'].includes(k))) throw new Error('Expected {"version":1,"tools":{...}}'); + for (const [name, value] of Object.entries(doc.tools)) { + if (!name || !value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid override: ${name}`); + const item = value as Record; + if (Object.keys(item).some(k => !['description', 'visible'].includes(k)) + || ('description' in item && (typeof item.description !== 'string' || item.description.length > 32768)) + || ('visible' in item && typeof item.visible !== 'boolean')) throw new Error(`Invalid description/visibility for ${name}`); + if (protectedNames.has(name) && item.visible === false) throw new Error(`Cannot hide recovery tool ${name}`); + } + return doc; +} + +export function presentationTools(path: string): ToolDefinition[] { + const signpost = ` Read the generated catalogue with workspace--read {"path":${JSON.stringify(path)},"limit":140}. Hidden tools remain available; changes affect the next newly compiled request, not an in-flight stream.`; + return [ + {name:'set_tool_visibility', description:'Show or hide a tool definition. Hiding does not revoke permission.' + signpost, + inputSchema:{type:'object',properties:{name:{type:'string'},visible:{type:'boolean'}},required:['name','visible'],additionalProperties:false}}, + {name:'set_tool_description', description:'Set a personal tool description; null restores the configured component default, or the installed description when none is configured.' + signpost, + inputSchema:{type:'object',properties:{name:{type:'string'},description:{type:['string','null']}},required:['name','description'],additionalProperties:false}}, + ] as unknown as ToolDefinition[]; +} + +/** Per-agent presentation; files are the sole persistent source, independent of editor identity. */ +export class ToolPresentation { + constructor(readonly config: ToolPresentationConfig) {} + private read(path = this.config.path, allowMissing = true): string { + let fd: number; + try { fd = openSync(path, 'r'); } + catch (error) { if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return '{"version":1,"tools":{}}'; throw error; } + try { + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error('Presentation must be a regular file under 256 KiB'); + const data = Buffer.alloc(MAX_BYTES + 1); + let size = 0, count = 0; + while (size < data.length && (count = readSync(fd, data, size, data.length - size, null)) > 0) size += count; + if (size > MAX_BYTES) throw new Error('Presentation exceeds 256 KiB'); + return data.subarray(0,size).toString('utf8'); + } finally { closeSync(fd); } + } + resolve(tools: ToolDefinition[], sources: ReadonlyMap = new Map()): PresentationSnapshot { + let raw = '', doc: PresentationDocument = {version:1,tools:{}}, diagnostics: string[] = []; + try { raw = this.read(); doc = parsePresentation(raw); } + catch (error) { diagnostics.push(`Overrides not applied: ${String(error)}. Default visibility and component descriptions retained.`); } + const defaults = new Map(); + const configuredSources = new Set(); + for (const profile of this.config.defaults ?? []) { + if (configuredSources.has(profile.source)) { + diagnostics.push(`Duplicate defaults source ignored: ${profile.source}`); continue; + } + configuredSources.add(profile.source); + // Absent components contribute neither tools nor missing-file errors. + if (!tools.some(t => (sources.get(t.name) ?? 'Unattributed') === profile.source)) continue; + try { + const profileDoc = parsePresentation(this.read(profile.path, false)); + if (Object.values(profileDoc.tools).some(item => Object.keys(item).some(k => k !== 'description'))) + throw new Error('Component defaults may contain descriptions only; visibility belongs to the resident'); + for (const tool of tools) { + if ((sources.get(tool.name) ?? 'Unattributed') !== profile.source) continue; + const item = Object.hasOwn(profileDoc.tools, tool.name) ? profileDoc.tools[tool.name] : undefined; + if (item?.description !== undefined) defaults.set(tool.name, {description:item.description,path:profile.path}); + } + } catch (error) { diagnostics.push(`Component defaults not applied (${profile.source}): ${String(error)}`); } + } + const names = new Set(tools.map(t => t.name)); + for (const name of Object.keys(doc.tools)) if (!names.has(name)) diagnostics.push(`Not currently available: ${name}`); + const entries = tools.map(tool => { + const override = Object.hasOwn(doc.tools, tool.name) ? doc.tools[tool.name] : undefined; + // Always retain a usable discovery signpost, even when its wording is overridden. + const componentDefault = defaults.get(tool.name); + let description = override?.description ?? componentDefault?.description ?? tool.description; + const descriptionSource = override?.description !== undefined ? this.config.path : componentDefault?.path ?? "installed component"; + if (isPresentationTool(tool.name) && (override?.description !== undefined || componentDefault !== undefined)) + description += `\nCatalogue: workspace--read {"path":${JSON.stringify(this.config.cataloguePath)},"limit":140}. Changes affect the next newly compiled request.`; + return {name:tool.name,source:sources.get(tool.name) ?? 'Unattributed',visible:override?.visible ?? true,originalDescription:tool.description,description,descriptionSource,inputSchema:structuredClone(tool.inputSchema)}; + }); + const available = tools.map((tool,i) => ({...structuredClone(tool),description:entries[i].description})); + return {revision:hash(JSON.stringify({raw,entries,diagnostics})),source:this.config.path,cataloguePath:this.config.cataloguePath,diagnostics,available, + advertised:available.filter((_,i)=>entries[i].visible),entries}; + } + edit(tool: string, input: unknown, available: ToolDefinition[]): ToolResult { + const lock = this.config.path + '.lock'; + let fd: number | undefined, tempFd: number | undefined, temp: string | undefined; + try { + if (!isPresentationTool(tool)) throw new Error('Unknown editing tool'); + const value = input as Record; + const field = tool === 'set_tool_visibility' ? 'visible' : 'description'; + if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.name !== 'string' + || Object.keys(value).some(k=> !['name',field].includes(k)) || !Object.hasOwn(value,field)) throw new Error('Invalid editing arguments'); + if (!available.some(t=>t.name===value.name)) throw new Error('Tool is not currently available to this agent'); + if (field === 'visible' ? typeof value.visible !== 'boolean' : value.description !== null && typeof value.description !== 'string') throw new Error(`Invalid ${field}`); + fd = openSync(lock, 'wx', 0o600); + let mode = 0o600; + try { const stat = lstatSync(this.config.path); if (stat.isSymbolicLink()) throw new Error('Edit the target file directly; tool editing refuses symlinks'); mode = stat.mode & 0o777; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + const before = this.read(), doc = parsePresentation(before); + const old = Object.hasOwn(doc.tools,value.name) ? doc.tools[value.name] : {}; + const updated = {...old}; + if (field === 'description' && value.description === null) delete updated.description; + else Object.assign(updated,{[field]:value[field]}); + Object.defineProperty(doc.tools,value.name,{value:updated,enumerable:true,writable:true,configurable:true}); + const next = JSON.stringify(doc,null,2)+'\n'; + parsePresentation(next); + if (Buffer.byteLength(next)>MAX_BYTES) throw new Error('Presentation exceeds 256 KiB'); + temp = this.config.path + '.' + randomUUID() + '.tmp'; + tempFd = openSync(temp,'wx',0o600); + writeFileSync(tempFd,next); + fchmodSync(tempFd,mode); // preserve permissions on the opened inode, never a replacement path + if (this.read() !== before) throw new Error('File changed concurrently; retry after reading it'); + const opened = fstatSync(tempFd), named = lstatSync(temp); + if (!named.isFile() || named.dev !== opened.dev || named.ino !== opened.ino) + throw new Error('Temporary file changed concurrently; retry after checking the directory'); + renameSync(temp,this.config.path); temp=undefined; + return {success:true,data:{name:value.name,[field]:value[field],effective:'next newly compiled request',catalogue:this.config.cataloguePath}}; + } catch (error) { return {success:false,isError:true,error:String(error)}; } + finally { if(tempFd!==undefined) closeSync(tempFd); if(temp) try{unlinkSync(temp);}catch{} if(fd!==undefined){closeSync(fd);unlinkSync(lock);} } + } +} +export function renderCatalogue(snapshot: PresentationSnapshot): string { + const groups = new Map(); + for (const entry of snapshot.entries) { + const group = groups.get(entry.source) ?? []; + group.push(entry); groups.set(entry.source, group); + } + const visible = snapshot.entries.filter(e => e.visible).length; + const lines: string[] = [ + '# Tool catalogue — generated from currently available tools', '', + `Revision: ${snapshot.revision}`, '', + `${snapshot.entries.length} tools: ${visible} visible, ${snapshot.entries.length-visible} hidden.`, + 'Visible = supplied in new requests. Hidden = stored away, still available.', + 'These are your visibility choices, not fixed primary/secondary rankings.', + 'Copy exact names from the index; similar-sounding names may not exist.', + 'Show a hidden tool: set_tool_visibility {"name":"EXACT_NAME","visible":true}.', + `Read details: workspace--read {"path":${JSON.stringify(snapshot.cataloguePath)},"offset":LINE,"limit":LINES}.`, + 'Line references below belong to this revision; reread the index after changes.', '', + ]; + for (const diagnostic of snapshot.diagnostics) lines.push('Diagnostic: '+diagnostic); + lines.push('## Index by source', ''); + const index = new Map(); + for (const [source, entries] of groups) { + lines.push(`### ${source} (${entries.length})`); + for (const entry of entries) {index.set(entry.name,lines.length);lines.push('');} + lines.push(''); + } + lines.push('## Full definitions', ''); + for (const [source, entries] of groups) { + lines.push(`### ${source}`, ''); + for (const entry of entries) { + const start = lines.length + 1; + lines.push(`#### ${entry.name} (${entry.visible?'visible':'hidden'})`, '', + ...entry.description.split('\n'), '', `Description from: ${entry.descriptionSource ?? 'installed component'}`, '', `Schema: ${JSON.stringify(entry.inputSchema)}`); + if(entry.description!==entry.originalDescription) lines.push('', 'Original description:', ...entry.originalDescription.split('\n')); + lines.push(''); + lines[index.get(entry.name)!] = `- [${entry.visible?'visible':'hidden'}] ${entry.name} — details: offset ${start}, limit ${lines.length-start+1}`; + } + } + return lines.join('\n'); +} diff --git a/src/types/agent.ts b/src/types/agent.ts index 929dbfe0..3c30ba91 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -12,6 +12,7 @@ export type SameRoundThinkTextPolicySource = * Configuration for an agent. */ export interface AgentConfig { + toolPresentation?: import("../tool-presentation.js").ToolPresentationConfig; /** Unique name for this agent */ name: string; diff --git a/test/component-defaults.test.ts b/test/component-defaults.test.ts new file mode 100644 index 00000000..398d564b --- /dev/null +++ b/test/component-defaults.test.ts @@ -0,0 +1,43 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,writeFileSync,readFileSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {ToolPresentation} from '../src/tool-presentation.js'; +test('component defaults are scoped, resident edits win, reset reveals default, missing components stay absent',()=>{ + const dir=mkdtempSync(join(tmpdir(),'component-defaults-')); + try { + const path=join(dir,'resident.json'),profile=join(dir,'discord.json'); + writeFileSync(profile,JSON.stringify({version:1,tools:{send:{description:'clear'},other:{description:'wrong source'}}})); + const p=new ToolPresentation({path,cataloguePath:'board/catalogue.md',defaults:[{source:'MCPL server: discord',path:profile},{source:'MCPL server: absent',path:join(dir,'absent.json')}]}); + const tools=[{name:'send',description:'original',inputSchema:{type:'object' as const}},{name:'other',description:'untouched',inputSchema:{type:'object' as const}}]; + const sources=new Map([['send','MCPL server: discord'],['other','Module: other']]); + assert.deepEqual(p.resolve(tools,sources).available.map(t=>t.description),['clear','untouched']); + assert.equal(p.resolve(tools,sources).diagnostics.length,0); + assert.equal(p.edit('set_tool_description',{name:'send',description:'mine'},tools).success,true); + p.edit('set_tool_visibility',{name:'send',visible:false},tools); + assert.equal(p.resolve(tools,sources).entries[0].description,'mine'); + p.edit('set_tool_description',{name:'send',description:null},tools); + assert.equal(p.resolve(tools,sources).entries[0].description,'clear'); + assert.equal(p.resolve(tools,sources).entries[0].visible,false); + assert.equal(JSON.parse(readFileSync(path,'utf8')).tools.send.description,undefined); + assert.equal(p.resolve([tools[1]],sources).available.length,1); + writeFileSync(profile,JSON.stringify({version:1,tools:{send:{visible:false,description:'bad'}}})); + assert.equal(p.resolve(tools,sources).entries[0].description,'original'); + assert.equal(p.resolve(tools,sources).diagnostics.length,1); + } finally {rmSync(dir,{recursive:true,force:true});} +}); + +test('component and resident descriptions both retain catalogue discovery on editing tools',()=>{ + const dir=mkdtempSync(join(tmpdir(),'component-signpost-')); + try { + const profile=join(dir,'framework.json'),path=join(dir,'resident.json'); + const tools=['set_tool_visibility','set_tool_description'].map(name=>({name,description:'installed',inputSchema:{type:'object' as const}})); + writeFileSync(profile,JSON.stringify({version:1,tools:Object.fromEntries(tools.map(t=>[t.name,{description:'profile wording'}]))})); + const p=new ToolPresentation({path,cataloguePath:'board/recovery.md',defaults:[{source:'Framework',path:profile}]}); + const sources=new Map(tools.map(t=>[t.name,'Framework'])); + for(const tool of p.resolve(tools,sources).advertised){assert.match(tool.description,/profile wording/);assert.match(tool.description,/workspace--read.*board\/recovery.md/);} + p.edit('set_tool_description',{name:'set_tool_visibility',description:'mine'},tools); + const own=p.resolve(tools,sources).advertised[0];assert.match(own.description,/mine/);assert.match(own.description,/workspace--read.*board\/recovery.md/); + } finally {rmSync(dir,{recursive:true,force:true});} +}); diff --git a/test/tool-presentation.test.ts b/test/tool-presentation.test.ts new file mode 100644 index 00000000..63b12188 --- /dev/null +++ b/test/tool-presentation.test.ts @@ -0,0 +1,222 @@ +import fs from 'node:fs'; +import {syncBuiltinESMExports} from 'node:module'; +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync,writeFileSync,readFileSync,chmodSync,statSync,existsSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {ToolPresentation,presentationTools,renderCatalogue} from '../src/tool-presentation.js'; +import {AgentFramework,WorkspaceModule} from '../src/index.js'; +import {AutobiographicalStrategy} from '@animalabs/context-manager'; + +test('edits persist, preserve originals, restore, reject invalid and protect recovery',()=>{ + const dir=mkdtempSync(join(tmpdir(),'presentation-')); + try { + const path=join(dir,'tools.json'), p=new ToolPresentation({path,cataloguePath:'board/tools.md'}); + const tools=[{name:'example',description:'original',inputSchema:{type:'object' as const}},...presentationTools('board/tools.md'),{name:'workspace--read',description:'read',inputSchema:{type:'object' as const}}]; + const first=p.resolve(tools); + assert.equal(p.edit('set_tool_visibility',{name:'example',visible:false},tools).success,true); + assert.equal(p.resolve(tools).advertised.some(t=>t.name==='example'),false); + assert.equal(p.resolve(tools).available.some(t=>t.name==='example'),true); + assert.equal(first.advertised.some(t=>t.name==='example'),true,'frozen prior result'); + assert.equal(p.edit('set_tool_description',{name:'example',description:'new'},tools).success,true); + assert.equal(tools[0].description,'original'); + assert.equal(new ToolPresentation(p.config).resolve(tools).available[0].description,'new'); + assert.equal(p.edit('set_tool_description',{name:'example',description:null},tools).success,true); + assert.equal(p.resolve(tools).available[0].description,'original'); + assert.equal(p.edit('set_tool_visibility',{name:'workspace--read',visible:false},tools).success,false); + assert.equal(p.edit('set_tool_visibility',{name:'unknown',visible:true},tools).success,false); + writeFileSync(path,JSON.stringify({version:1,tools:{example:{description:'file edit',visible:true}}})); + assert.equal(p.resolve(tools).advertised[0].description,'file edit'); + writeFileSync(path,'broken'); + assert.equal(p.resolve(tools).advertised[0].description,'original'); + assert.equal(p.resolve(tools).diagnostics.length,1); + assert.equal(p.edit('set_tool_visibility',{name:'example',visible:true},tools).success,false); + assert.equal(readFileSync(path,'utf8'),'broken'); + } finally {rmSync(dir,{recursive:true,force:true});} +}); + +test('framework preview, generated workspace read and both edit dispatch paths agree',async()=>{ + const dir=mkdtempSync(join(tmpdir(),'presentation-framework-')); + const workspace=new WorkspaceModule({mounts:[{name:'board',path:dir,mode:'read-write',watch:'never'}]}); + const strategy=()=>new AutobiographicalStrategy({adaptiveResolution:true,foldingStrategy:'kv-stable',recentWindowTokens:30000,kvStableReachTokens:8000}); + const framework=await AgentFramework.create({storePath:join(dir,'store'),membrane:{} as any,agents:[ + {name:'ada',model:'test',systemPrompt:'.',strategy:strategy(),toolPresentation:{path:join(dir,'tools.json'),cataloguePath:'board/tools.md'}}, + {name:'other',model:'test',systemPrompt:'.',strategy:strategy()}, + ],modules:[workspace]}); + try { + const before=framework.inspectToolPresentation('ada')!; + assert.equal(before.advertised.filter(t=>t.name.startsWith('set_tool_')).length,2); + const target='workspace--glob'; assert.ok(before.available.some(t=>t.name===target)); + const call=(name:string,input:unknown)=>framework.executeToolCall({id:'test' as any,name,input:input as any,callerAgentName:'ada'}); + assert.equal((await call('set_tool_visibility',{name:target,visible:false})).success,true); + const snapshot=framework.inspectToolPresentation('ada')!; + const agent=(framework as any).agents.get('ada'); + const cm=agent.getContextManager(); + const setDefinitions=cm.setToolDefinitions.bind(cm); + let compressionTools:any[]=[]; + cm.setToolDefinitions=(definitions:any[])=>{compressionTools=definitions;setDefinitions(definitions);}; + const preview=await framework.previewActivation('ada'); + assert.ok(compressionTools.some(t=>t.name===target),'preview retains hidden historical definitions for compression'); + const ready=cm.isReady;cm.isReady=()=>true; + try {compressionTools=[];await (framework as any).runQueuedMaintenance();} + finally {cm.isReady=ready;} + assert.ok(compressionTools.some(t=>t.name===target),'maintenance retains hidden historical definitions'); + assert.ok(!(framework as any).agentToolSurface(agent).some((t:any)=>t.name===target),'RFC-008 listing follows advertised visibility'); + // The live streaming compiler must not overwrite the compression surface + // with visible-only definitions after a preview/maintenance refresh. + const membrane=agent.membrane; + agent.membrane={streamYielding:(request:any)=>{ + assert.ok(!request.tools.some((t:any)=>t.name===target)); + return {cancel(){}}; + }}; + try { + compressionTools=[]; + await agent.startStreamWithInjections(snapshot.advertised,undefined,undefined,snapshot.available); + assert.ok(compressionTools.some(t=>t.name===target),'live stream retains hidden definitions for compression'); + } finally {agent.cancelStream();agent.membrane=membrane;} + + + assert.deepEqual(preview.tools,snapshot.advertised); + assert.ok((framework as any).getToolsForAgent('ada').some((t:any)=>t.name===target),'execution surface unchanged'); + assert.ok(!(await framework.previewActivation('other')).tools?.some(t=>t.name==='set_tool_visibility')); + const read=await call('workspace--read',{path:'board/tools.md'}); + assert.equal(read.success,true);assert.match(JSON.stringify(read.data),/workspace--glob \(hidden\)/); + assert.equal((await call('workspace--write',{path:'board/tools.md',content:'overwrite'})).success,false); + // Real model dispatch queues a ToolCallEvent; test the whole native route, + // not only executeToolCall (which already carries callerAgentName). + const nativeRead = async (agentName: string) => { + const oldPush = (framework as any).pushEvent; + try { + return await new Promise((resolve) => { + (framework as any).pushEvent = (event: any) => { + if (event.type === 'tool-call') (framework as any).dispatchToolCallEvent(event); + else if (event.type === 'tool-result') resolve(event.result); + }; + (framework as any).dispatchToolCall(agentName, { + id:'native-catalogue-read', name:'workspace--read', input:{path:'board/tools.md'}, + callerAgentName:'spoofed-identity', + }); + }); + } finally { (framework as any).pushEvent = oldPush; } + }; + const nativeResult = await nativeRead('ada'); + assert.equal(nativeResult.success,true, nativeResult.error); + assert.match(JSON.stringify(nativeResult.data),/workspace--glob \(hidden\)/); + assert.equal((await nativeRead('other')).success,false,'catalogue remains agent scoped'); + + const events:any[]=[];const original=(framework as any).pushEvent; + (framework as any).pushEvent=(e:any)=>events.push(e); + (framework as any).dispatchToolCall('ada',{id:'restore',name:'set_tool_visibility',input:{name:target,visible:true}}); + (framework as any).pushEvent=original; + assert.equal(events[0].result.success,true); + assert.ok(framework.inspectToolPresentation('ada')!.advertised.some(t=>t.name===target)); + assert.equal((await framework.executeToolCall({id:'denied' as any,name:'set_tool_visibility',input:{name:target,visible:false},callerAgentName:'other'})).success,false); + } finally {await framework.stop();rmSync(dir,{recursive:true,force:true});} +}); + +test('catalogue source groups and line references follow the live snapshot',()=>{ + const dir=mkdtempSync(join(tmpdir(),'catalogue-groups-')); + try { + const p=new ToolPresentation({path:join(dir,'tools.json'),cataloguePath:'board/tools.md'}); + const tools=[{name:'custom--fetch',description:'First line\nSecond line',inputSchema:{type:'object' as const}}, + {name:'workspace--read',description:'Read',inputSchema:{type:'object' as const}}]; + const sources=new Map([['custom--fetch','MCPL server: web'],['workspace--read','Module: workspace']]); + writeFileSync(p.config.path,JSON.stringify({version:1,tools:{'custom--fetch':{visible:false}}})); + const text=renderCatalogue(p.resolve(tools,sources)); + assert.match(text,/MCPL server: web \(1\)/); assert.match(text,/Module: workspace \(1\)/); + assert.match(text,/2 tools: 1 visible, 1 hidden/); + const lines=text.split('\n'); + for(const name of tools.map(t=>t.name)) { + const row=lines.find(l=>l.startsWith('- [') && l.includes(name))!; + const match=row.match(/offset (\d+), limit (\d+)/)!; + const start=Number(match[1])-1,count=Number(match[2]); + assert.ok(lines[start].startsWith('#### '+name)); + assert.ok(lines.slice(start,start+count).some(l=>l.startsWith('Schema:'))); + } + assert.ok(!renderCatalogue(p.resolve([tools[1]],sources)).includes('### MCPL server: web')); + } finally {rmSync(dir,{recursive:true,force:true});} +}); + + +test('edits preserve group-write permissions even under a restrictive umask',()=>{ + const dir=mkdtempSync(join(tmpdir(),'presentation-mode-')); + const originalMask=process.umask(0o077); + try { + const path=join(dir,'tools.json');writeFileSync(path,'{"version":1,"tools":{}}');chmodSync(path,0o664); + const p=new ToolPresentation({path,cataloguePath:'board/tools.md'}); + assert.equal(p.edit('set_tool_visibility',{name:'example',visible:false},[{name:'example',description:'original',inputSchema:{type:'object'}}]).success,true); + assert.equal(statSync(path).mode & 0o777,0o664); + } finally {process.umask(originalMask);rmSync(dir,{recursive:true,force:true});} +}); + +test('catalogue aliases preserve generated content, ownership and read-only access',async()=>{ + const dir=mkdtempSync(join(tmpdir(),'catalogue-alias-')); + const workspace=new WorkspaceModule({mounts:[{name:'board',path:dir,mode:'read-write',watch:'never',autoMaterialize:true},{name:'alias',path:dir,mode:'read-write',watch:'never',autoMaterialize:true}]}); + try { + workspace.registerGeneratedTextFile('board/tools.md',()=> 'generated catalogue','resident'); + for(const path of ['board/tools.md','board/./tools.md','board/sub/../tools.md','alias/tools.md']) { + const call=(name:string,callerAgentName='resident')=>workspace.handleToolCall({id:'alias',name,callerAgentName,input:{path,content:'overwrite',oldString:'generated',newString:'bad'}}); + const read=await call('read');assert.equal(read.success,true,read.error);assert.match(JSON.stringify(read.data),/generated catalogue/); + assert.equal((await call('read','other')).success,false); + for(const name of ['write','edit','delete','materialize','sync'])assert.equal((await call(name)).success,false,name+' '+path); + } + assert.equal((await workspace.handleToolCall({id:'absolute',name:'read',callerAgentName:'resident',input:{path:'board//tools.md'}})).success,false); + assert.equal(existsSync(join(dir,'tools.md')),false); + assert.throws(()=>workspace.registerGeneratedTextFile('alias/tools.md',()=> 'duplicate','resident'),/Duplicate/); + assert.equal((await workspace.handleToolCall({id:'outside',name:'write',input:{path:'board/../outside',content:'no'}})).success,false); + } finally {rmSync(dir,{recursive:true,force:true});} +}); + +test('live compilation captures advertised and compression definitions before context hooks refresh tools',async()=>{ + const dir=mkdtempSync(join(tmpdir(),'presentation-refresh-')); + const path=join(dir,'tools.json'); + writeFileSync(path,JSON.stringify({version:1,tools:{hidden:{visible:false}}})); + const framework=await AgentFramework.create({storePath:join(dir,'store'),membrane:{} as any, + agents:[{name:'ada',model:'test',systemPrompt:'.',toolPresentation:{path,cataloguePath:'board/tools.md'}}], + modules:[new WorkspaceModule({mounts:[{name:'board',path:dir,mode:'read-write',watch:'never'}]})]}); + try { + const internal=framework as any, agent=internal.agents.get('ada'); + const original=[{name:'visible',description:'before',inputSchema:{type:'object'}},{name:'hidden',description:'historical',inputSchema:{type:'object'}}]; + let current=original; + internal.getToolsForAgent=()=>current; + internal.moduleRegistry.gatherContext=async()=>{ + await Promise.resolve(); + original[0].description='mutated'; + current=[{name:'replacement',description:'after',inputSchema:{type:'object'}}]; + return []; + }; + let captured:any[]|undefined; + agent.startStreamWithInjections=async(...args:any[])=>{captured??=args;throw new Error('test: stop before provider call');}; + await internal.startAgentStream(agent); + assert.ok(captured,'reached live compiler'); + assert.deepEqual(captured[0].map((t:any)=>[t.name,t.description]),[['visible','before']]); + assert.deepEqual(captured[3].map((t:any)=>[t.name,t.description]),[['visible','before'],['hidden','historical']]); + } finally {await framework.stop();rmSync(dir,{recursive:true,force:true});} +}); + +test('replaced temporary pathname cannot redirect permission changes',()=>{ + const dir=mkdtempSync(join(tmpdir(),'presentation-temp-race-')); + const path=join(dir,'tools.json'), victim=join(dir,'private'); + const before=JSON.stringify({version:1,tools:{}}); + writeFileSync(path,before);chmodSync(path,0o664); + writeFileSync(victim,'private');chmodSync(victim,0o600); + const originalWrite=fs.writeFileSync; + let replaced=false; + try { + fs.writeFileSync=((...args:any[])=>{ + (originalWrite as any)(...args); + const temp=fs.readdirSync(dir).find(name=>name.endsWith('.tmp')); + if(temp&&!replaced){replaced=true;fs.unlinkSync(join(dir,temp));fs.symlinkSync(victim,join(dir,temp));} + }) as typeof fs.writeFileSync; + syncBuiltinESMExports(); + const p=new ToolPresentation({path,cataloguePath:'board/tools.md'}); + const result=p.edit('set_tool_visibility',{name:'example',visible:false},[{name:'example',description:'example',inputSchema:{type:'object'}}]); + assert.equal(replaced,true,'injected replacement after writing'); + assert.equal(statSync(victim).mode&0o777,0o600,'private target mode unchanged'); + assert.equal(readFileSync(victim,'utf8'),'private'); + assert.equal(result.success,false,'detected replacement refuses commit'); + assert.equal(readFileSync(path,'utf8'),before); + assert.deepEqual(fs.readdirSync(dir).sort(),['private','tools.json']); + } finally {fs.writeFileSync=originalWrite;syncBuiltinESMExports();rmSync(dir,{recursive:true,force:true});} +});