From 857f6f119a1500609cfcb608d422e3fcac85623c Mon Sep 17 00:00:00 2001 From: "lari@tesserae.cc" Date: Sat, 19 Sep 2026 17:43:04 -0700 Subject: [PATCH 1/3] feat(tools): add resident-owned tool presentation and component defaults --- changelog.d/tool-presentation.added.md | 1 + docs/tool-presentation.md | 29 ++++ src/framework.ts | 90 ++++++++++-- src/index.ts | 2 + src/modules/workspace/index.ts | 25 +++- src/tool-presentation.ts | 183 +++++++++++++++++++++++++ src/types/agent.ts | 1 + test/component-defaults.test.ts | 29 ++++ test/tool-presentation.test.ts | 112 +++++++++++++++ 9 files changed, 459 insertions(+), 13 deletions(-) create mode 100644 changelog.d/tool-presentation.added.md create mode 100644 docs/tool-presentation.md create mode 100644 src/tool-presentation.ts create mode 100644 test/component-defaults.test.ts create mode 100644 test/tool-presentation.test.ts diff --git a/changelog.d/tool-presentation.added.md b/changelog.d/tool-presentation.added.md new file mode 100644 index 00000000..4100b6e8 --- /dev/null +++ b/changelog.d/tool-presentation.added.md @@ -0,0 +1 @@ +- 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. diff --git a/docs/tool-presentation.md b/docs/tool-presentation.md new file mode 100644 index 00000000..9f2bfc2b --- /dev/null +++ b/docs/tool-presentation.md @@ -0,0 +1,29 @@ +# 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 context maintenance share advertised-definition assembly. `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. diff --git a/src/framework.ts b/src/framework.ts index 3bad5767..58e83c3e 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'; @@ -901,6 +902,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; @@ -1481,6 +1488,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 @@ -1498,6 +1506,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) { @@ -2025,7 +2044,7 @@ export class AgentFramework { private async runQueuedMaintenance(): Promise { const queued = [...this.agents.values()].flatMap((agent) => { const cm = agent.getContextManager(); - const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name)); + const tools = this.advertisedToolsForAgent(agent.name); cm.setToolDefinitions(tools); if (this.providerGateBlocked(agent.name)) return []; if (cm.isReady()) return []; @@ -2485,7 +2504,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 @@ -2496,6 +2515,47 @@ 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); + } + + 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)); + } + getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot { const agent = this.agents.get(agentName); if (!agent) throw new Error(`Unknown agent: ${agentName}`); @@ -3312,7 +3372,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 @@ -3321,7 +3386,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)); } // Full-fidelity path: mirrors startAgentStream's injection gathering. @@ -3356,7 +3421,7 @@ export class AgentFramework { } } - return agent.buildActivationRequest(tools, injections, opts?.budget); + return capture(await agent.buildActivationRequest(tools, injections, opts?.budget)); } /** @@ -8227,11 +8292,7 @@ export class AgentFramework { try { const requestSnapshot = this.captureInferenceToolSnapshot(agent); - const allTools = this.getToolsForAgent(agent.name, requestSnapshot); - const tools = allTools.filter((t) => agent.canUseTool(t.name)); - // Explicit-mode agents get the on-demand routing reference (teach-by- - // bounce: the grammar is never injected, only served when asked). - if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL); + const tools = this.advertisedToolsForAgent(agent.name, requestSnapshot); // Gather context from modules (pull-based) and MCPL hooks (push-based) // Both produce ContextInjection[] that get merged before inference. @@ -9891,6 +9952,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. @@ -10865,6 +10927,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); @@ -11032,7 +11100,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 26345560..bef26165 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,3 +133,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..1a0a6453 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1075,9 +1075,27 @@ export class WorkspaceModule implements Module { // Tool Dispatch // ========================================================================== + private generatedTextFiles = new Map string; agentName: string}>(); + + /** 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(path)) + throw new Error(`Duplicate/invalid generated path: ${path}`); + this.generatedTextFiles.set(path, {read, agentName}); + } + async handleToolCall(call: ToolCall): Promise { try { const input = call.input as Record; + const generated = typeof input?.path === 'string' ? this.generatedTextFiles.get(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 +1764,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 +1778,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 +1795,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..994edeef --- /dev/null +++ b/src/tool-presentation.ts @@ -0,0 +1,183 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { 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) + 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, 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'; + writeFileSync(temp,next,{flag:'wx',mode}); + if (this.read() !== before) throw new Error('File changed concurrently; retry after reading it'); + 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(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 b5cd7471..bd73483f 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..82b575ff --- /dev/null +++ b/test/component-defaults.test.ts @@ -0,0 +1,29 @@ +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});} +}); diff --git a/test/tool-presentation.test.ts b/test/tool-presentation.test.ts new file mode 100644 index 00000000..5f8bf3c1 --- /dev/null +++ b/test/tool-presentation.test.ts @@ -0,0 +1,112 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync,writeFileSync,readFileSync} 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 preview=await framework.previewActivation('ada'); + 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});} +}); From 0dc34a689ca698c0b794d063476df1a94c1b7588 Mon Sep 17 00:00:00 2001 From: "lari@tesserae.cc" Date: Tue, 6 Oct 2026 08:20:12 -0700 Subject: [PATCH 2/3] fix(tools): preserve compression history and catalogue recovery guarantees Keep hidden definitions available to compression, canonicalize generated-file aliases, retain catalogue signposts under component defaults, and preserve file modes across atomic edits. Co-Authored-By: GPT-6 --- changelog.d/tool-presentation.added.md | 1 + docs/tool-presentation.md | 4 +- src/agent.ts | 13 +++--- src/framework.ts | 14 +++++-- src/modules/workspace/index.ts | 27 ++++++++++-- src/tool-presentation.ts | 5 ++- test/component-defaults.test.ts | 14 +++++++ test/tool-presentation.test.ts | 57 +++++++++++++++++++++++++- 8 files changed, 119 insertions(+), 16 deletions(-) diff --git a/changelog.d/tool-presentation.added.md b/changelog.d/tool-presentation.added.md index 4100b6e8..d26bd81d 100644 --- a/changelog.d/tool-presentation.added.md +++ b/changelog.d/tool-presentation.added.md @@ -1 +1,2 @@ - 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. diff --git a/docs/tool-presentation.md b/docs/tool-presentation.md index 9f2bfc2b..e37a37e0 100644 --- a/docs/tool-presentation.md +++ b/docs/tool-presentation.md @@ -14,7 +14,7 @@ Removing an entry restores its defaults. Unknown/unavailable names are retained 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 context maintenance share advertised-definition assembly. `inspectToolPresentation` returns current state; `getRequestToolPresentation(previewRequest)` returns the matching frozen preview metadata. +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. @@ -27,3 +27,5 @@ Optional `toolPresentation.defaults` is an array of `{source, path}` profiles, w 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 096db938..5e2da2d2 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2219,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.advertisedToolsForAgent(agent.name); + const tools = this.compressionToolsForAgent(agent.name); cm.setToolDefinitions(tools); if (this.providerGateBlocked(agent.name)) return []; if (cm.isReady()) return []; @@ -2779,6 +2779,12 @@ export class AgentFramework { ?? 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); @@ -3630,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 capture(await 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. @@ -3665,7 +3671,7 @@ export class AgentFramework { } } - return capture(await agent.buildActivationRequest(tools, injections, opts?.budget)); + return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools)); } /** @@ -9192,7 +9198,7 @@ export class AgentFramework { request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, - } = await agent.startStreamWithInjections(tools, injections); + } = await agent.startStreamWithInjections(tools, injections, undefined, this.compressionToolsForAgent(agent.name, requestSnapshot)); if (this.agents.get(agent.name) !== agent) { stream.cancel(); agent.cancelStream(); diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 1a0a6453..9091ef1f 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -1077,19 +1077,40 @@ export class WorkspaceModule implements Module { 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(path)) + || parts.some(p => !p || p === '.' || p === '..') || this.generatedTextFiles.has(this.generatedFileKey(path))) throw new Error(`Duplicate/invalid generated path: ${path}`); - this.generatedTextFiles.set(path, {read, agentName}); + 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.generatedTextFiles.get(input.path) : undefined; + 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'}; diff --git a/src/tool-presentation.ts b/src/tool-presentation.ts index 994edeef..a5bca085 100644 --- a/src/tool-presentation.ts +++ b/src/tool-presentation.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { closeSync, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { chmodSync, closeSync, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import type { ToolDefinition, ToolResult } from './types/events.js'; export interface ToolPresentationConfig { @@ -100,7 +100,7 @@ export class ToolPresentation { 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) + 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)}; }); @@ -134,6 +134,7 @@ export class ToolPresentation { if (Buffer.byteLength(next)>MAX_BYTES) throw new Error('Presentation exceeds 256 KiB'); temp = this.config.path + '.' + randomUUID() + '.tmp'; writeFileSync(temp,next,{flag:'wx',mode}); + chmodSync(temp,mode); // creation modes are filtered by the process umask if (this.read() !== before) throw new Error('File changed concurrently; retry after reading it'); 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}}; diff --git a/test/component-defaults.test.ts b/test/component-defaults.test.ts index 82b575ff..398d564b 100644 --- a/test/component-defaults.test.ts +++ b/test/component-defaults.test.ts @@ -27,3 +27,17 @@ test('component defaults are scoped, resident edits win, reset reveals default, 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 index 5f8bf3c1..fe1d16c9 100644 --- a/test/tool-presentation.test.ts +++ b/test/tool-presentation.test.ts @@ -1,6 +1,6 @@ import {test} from 'node:test'; import assert from 'node:assert/strict'; -import {mkdtempSync,rmSync,writeFileSync,readFileSync} from 'node:fs'; +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'; @@ -49,7 +49,32 @@ test('framework preview, generated workspace read and both edit dispatch paths a 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')); @@ -110,3 +135,33 @@ test('catalogue source groups and line references follow the live snapshot',()=> 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});} +}); From 7387990955012627bb9ba895c25fdf16b48702fb Mon Sep 17 00:00:00 2001 From: "lari@tesserae.cc" Date: Tue, 6 Oct 2026 08:53:50 -0700 Subject: [PATCH 3/3] fix: capture tool surfaces together and chmod edits by descriptor Add regressions for mid-gather tool refresh and temporary-path replacement. Co-Authored-By: GPT-6 --- changelog.d/tool-presentation.added.md | 2 + src/framework.ts | 8 +++- src/tool-presentation.ts | 14 ++++--- test/tool-presentation.test.ts | 55 ++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/changelog.d/tool-presentation.added.md b/changelog.d/tool-presentation.added.md index d26bd81d..bab93362 100644 --- a/changelog.d/tool-presentation.added.md +++ b/changelog.d/tool-presentation.added.md @@ -1,2 +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/src/framework.ts b/src/framework.ts index 5e2da2d2..519e3042 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9126,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. @@ -9198,7 +9202,7 @@ export class AgentFramework { request: compiledRequest, takeKvSubmission, drainKvSubmissionIds, - } = await agent.startStreamWithInjections(tools, injections, undefined, this.compressionToolsForAgent(agent.name, requestSnapshot)); + } = await agent.startStreamWithInjections(tools, injections, undefined, compressionTools); if (this.agents.get(agent.name) !== agent) { stream.cancel(); agent.cancelStream(); diff --git a/src/tool-presentation.ts b/src/tool-presentation.ts index a5bca085..8fd9eef4 100644 --- a/src/tool-presentation.ts +++ b/src/tool-presentation.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { chmodSync, closeSync, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { fchmodSync, closeSync, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import type { ToolDefinition, ToolResult } from './types/events.js'; export interface ToolPresentationConfig { @@ -110,7 +110,7 @@ export class ToolPresentation { } edit(tool: string, input: unknown, available: ToolDefinition[]): ToolResult { const lock = this.config.path + '.lock'; - let fd: number | undefined, temp: string | undefined; + 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; @@ -133,13 +133,17 @@ export class ToolPresentation { parsePresentation(next); if (Buffer.byteLength(next)>MAX_BYTES) throw new Error('Presentation exceeds 256 KiB'); temp = this.config.path + '.' + randomUUID() + '.tmp'; - writeFileSync(temp,next,{flag:'wx',mode}); - chmodSync(temp,mode); // creation modes are filtered by the process umask + 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(temp) try{unlinkSync(temp);}catch{} if(fd!==undefined){closeSync(fd);unlinkSync(lock);} } + 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 { diff --git a/test/tool-presentation.test.ts b/test/tool-presentation.test.ts index fe1d16c9..63b12188 100644 --- a/test/tool-presentation.test.ts +++ b/test/tool-presentation.test.ts @@ -1,3 +1,5 @@ +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'; @@ -165,3 +167,56 @@ test('catalogue aliases preserve generated content, ownership and read-only acce 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});} +});