Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/tool-presentation.added.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions docs/tool-presentation.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 8 additions & 5 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,16 +783,18 @@ export class Agent {
async buildActivationRequest(
availableTools: ToolDefinition[],
injections?: ContextInjection[],
budget?: TokenBudget
budget?: TokenBudget,
compressionTools: ToolDefinition[] = availableTools
): Promise<NormalizedRequest> {
// 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 {
Expand Down Expand Up @@ -873,7 +875,8 @@ export class Agent {
async startStreamWithInjections(
availableTools: ToolDefinition[],
injections?: ContextInjection[],
budget?: TokenBudget
budget?: TokenBudget,
compressionTools: ToolDefinition[] = availableTools
): Promise<StartStreamResult> {
if (this._state.status !== 'idle') {
throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`);
Expand Down Expand Up @@ -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 })
Expand Down
102 changes: 91 additions & 11 deletions src/framework.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -949,6 +950,12 @@ function truncateReason(reason: string, max = 160): string {
}

export class AgentFramework {
private toolPresentations = new Map<string, ToolPresentation>();
private presentationPreviews = new WeakMap<object, PresentationSnapshot>();
getRequestToolPresentation(request: object): PresentationSnapshot | null {
return this.presentationPreviews.get(request) ?? null;
}

private store: JsStore;
private ownsStore: boolean;
private membrane: Membrane;
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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
Expand All @@ -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<string, string>();
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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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));
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -10893,6 +10966,7 @@ export class AgentFramework {
}

private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,5 @@ export type {
OfflineRecoveryBranchOptions,
OfflineRecoveryBranchResult,
} from './recovery/offline-branch.js';

export * from "./tool-presentation.js";
Loading
Loading