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
37 changes: 29 additions & 8 deletions docs/references/agent/agent-tools-and-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ live listing fails closed.
The snapshot contains the real executable callbacks. Pi cannot execute arbitrary application
functions: every target must still exist in the current authorized catalog.

MCP capabilities and low-frequency Skill installation/adaptation tools register as upstream `deferred`
tools. Upstream `tool_search` ranks names,
MCP capabilities and built-in tools marked `deferred` (Skill installation/adaptation and library
management) register as upstream `deferred` tools. Upstream `tool_search` ranks names,
descriptions and parameter metadata with BM25, then loads matching real tools for the next model
request through `control.addTools`. The default result limit is eight; the query must be nonempty.
Loaded tools remain available in that Pi conversation, including after compaction. Cherry retains
Expand Down Expand Up @@ -242,12 +242,33 @@ name search, and subfolder creation use `list_workspace`, `find_workspace_files`
`create_workspace_folder`. Writes remain in the captured workspace; the commit transaction checks
live destination ancestry. Without a workspace, new outputs use the library root.

The `fileLibrary` capability adds `library_search_files` and `library_read_file`, enabled by default
through the Agent's deny-list model. Every call, including pagination, uses `ask` with
`autoApprovalEligible: false`; automatic approval cannot bypass consent. An approved read permits
editing that source into a workspace copy during this turn and leaves the source unchanged. It does
not grant `read_file` future outside-library access. The Agent has no delete, move, restore, or purge
tool. `generate_image` still scopes input images to conversation references; outputs use the
The `fileLibrary` capability adds library search/read and management tools, enabled by default
through the Agent's deny-list model. They follow the shared approval rule: reads
(`library_search_files`, `library_read_file`, `library_list_folders`, `library_list_trash`) are
`auto`, while move, rename, trash, and restore are `ask`. Each tool is auto-approval eligible, so the
Agent's approval mode decides whether a mutation waits for the user. A successful library read
permits editing that source into a workspace copy during this turn and leaves the source unchanged.
It does not grant `read_file` future outside-library access.

The six management tools are deferred; `library_search_files` names them for `tool_search`.
`library_list_folders` searches active folder names or lists direct children, returning stable ids
and parent ids with bounded pagination. `library_search_files` returns both the logical `fileId`
and its `folderId`, alongside the current version's `fileEntryId`. Names may be ambiguous: move,
rename, and trash tools accept exact logical file or folder ids, never arbitrary paths or version ids.
`library_move_item` moves a file with its versions or a folder subtree, and `library_rename_item`
changes only its display name. The existing library service owns cycle prevention, active
destinations, collision rules, transactions, and cache invalidation. Move results report any
collision suffix selected by that service.

`library_trash_item` moves a file with all versions or an active folder subtree into the existing
30-day trash. `library_list_trash` pages recoverable files and folders; `library_restore_item`
restores one group by its exact `kind` and `id`. Both use the host-owned `TrashRuntime`, the same
owner as the library screen, which notifies file subscribers after commit. These tools work without
a selected workspace. Mutations recheck turn cancellation when their write transaction starts and
before it commits; cancellation rolls back the whole change. The Agent cannot permanently purge
items or empty the trash.

`generate_image` still scopes input images to conversation references; outputs use the
workspace, as do direct image generation and managed MCP result files. Code Mode calls these same
authorized tools and has no filesystem of its own.

Expand Down
6 changes: 3 additions & 3 deletions docs/references/data/file-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,9 @@ checking the current pointer and revision inside the write transaction. Later tu
version under the same logical name. A failed save removes its uncommitted blob.

`read_file` and `edit_file` accept conversation references and workspace files. Outside-library
search and reads use `library_search_files` and `library_read_file`; every call requires confirmation
and is ineligible for automatic approval. An approved read permits editing that source during the
turn, but never grants unrestricted reads. Editing an outside source creates a workspace copy.
search and reads use `library_search_files` and `library_read_file`, which are `auto` like other
reads. A successful read permits editing that source during the turn, but never grants unrestricted
reads. Editing an outside source creates a workspace copy.
`generate_image`, direct image generation, Code Mode tool calls and saved script outputs, and stored
MCP results use the captured workspace destination. Tool outputs contain managed ids and revisions,
never device paths. A workspace turn names its folder in `list_workspace`, and the system prompt
Expand Down
13 changes: 7 additions & 6 deletions docs/references/data/trash.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# File Trash

The trash retains user-deleted library files, versions, and folders for 30 days. The library's More menu opens `/trash`,
The trash retains user-deleted library files and folders for 30 days. The library's More menu opens `/trash`,
which presents Recently deleted as compact thumbnail/name/days-left rows beneath one notice with the
retention period and total trashed file size. Tapping a row opens Restore and Permanently delete;
the header menu offers Empty trash. Trash image thumbnails resolve only the matching deletion
Expand All @@ -12,11 +12,12 @@ deletion remains immediate and does not move attachments to trash.
There is no duplicate trash table. `file_folder.deletedAt`, `library_file.deletedAt`, and
`file_entry.deletedAt` own recoverable membership. `trashedWith` groups descendants and versions.
`TrashService` projects root items into one cursor stream, ordered by deletion time and `kind:id`.
File thumbnails use the logical file's current version; individual versions use their own entry id.
File thumbnails use the logical file's current version.
Folders display a folder icon.

- Library deletion uses `FileModule.moveToTrash`. Normal file reads, Agent attachment/edit paths,
and previews treat trashed files as unavailable. Restore clears the deletion mark, keeping the
- `TrashRuntime` owns trashing for the library screen (`FileModule.moveToTrash` and folder
`DELETE`) and Agent tools, notifying file subscribers after commit. Normal file reads, Agent
attachment/edit paths, and previews treat trashed files as unavailable. Restore clears the deletion mark, keeping the
entry id and content/preview revision. Historical references work again when their file is restored.
Draft rewrites check availability and write bytes and metadata within the same serialized database
transaction as trash mutations, so a queued deletion cannot split those writes.
Expand All @@ -40,8 +41,8 @@ trashed records with their original deletion time. After a backup restore and re
sweep removes expired items. Cache thumbnails are derived and are regenerated as needed.

Trashing a logical file includes every version. Trashing a folder includes its active subtree and
those files' versions as one item; separately trashed subtrees stay independent. Users may also
trash old versions individually. Restore reactivates the group, retaining content revision keys;
those files' versions as one item; separately trashed subtrees stay independent. Old versions are
never trashed individually. Restore reactivates the group, retaining content revision keys;
when the former parent is unavailable, the restored root moves to the library root with a collision
suffix if needed. Permanent folder deletion clears Session bindings. Deleting a Session leaves its
workspace folder and files intact.
4 changes: 4 additions & 0 deletions src/backend/ai/agent/host/AgentHostDependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
createSystemCapabilitySource,
type SystemCapabilitySource,
} from '../tools/builtInToolSource';
import type { FileLibraryTrashAccess } from '../tools/fileLibraryManagementTools';
import { createAgentRuntimeToolResolver } from '../tools/runtimeTools';
import { type AgentDefinitionSource, createAgentTableDefinitionSource } from './agentDefinitions';
import { createAgentImageGeneration } from './agentImageGeneration';
Expand All @@ -69,6 +70,7 @@ const logger = loggerService.withContext('AgentHostDependencies');
'WebSearchService',
'TraceStorageService',
'AgentRuntime',
'TrashRuntime',
'KeepAliveCoordinator',
])
export class AgentHostDependencies extends BaseService implements MobileAgentHostPorts {
Expand Down Expand Up @@ -152,6 +154,7 @@ export class AgentHostDependencies extends BaseService implements MobileAgentHos
private readonly webSearchService: WebSearchService,
readonly traces: TraceRecorder,
private readonly textGenerator: RuntimeTextGenerator,
private readonly trash: FileLibraryTrashAccess,
private readonly keepAlive: KeepAliveSource = { acquire: () => ({ release() {} }) },
) {
super();
Expand Down Expand Up @@ -195,6 +198,7 @@ export class AgentHostDependencies extends BaseService implements MobileAgentHos
model: modelService,
preference: this.preferenceService,
webSearch: this.webSearchService,
trash: this.trash,
}));
}

Expand Down
1 change: 1 addition & 0 deletions src/backend/ai/agent/host/DurableAgentHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,7 @@ function configurationKey(configuration: RuntimeConversationSeed['configuration'
approval: tool.approval,
failureGroup: tool.failureGroup,
autoApprovalEligible: tool.autoApprovalEligible,
deferred: tool.deferred,
})),
webTools: configuration.webTools,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ describe('Persistent Agent facade', () => {
description: 'Find available skills.',
inputSchema: { type: 'object', properties: {} },
approval: 'auto',
deferred: true,
execute: async () => {
throw new Error('Recovery must not replay tools.');
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { EntryRecord, SubmissionRecord } from '@earendil-works/pi-durable';

import { buildAgentSystemPrompt } from '../../../host/agentSystemPrompt';
import { toAgentMessagePart } from '../../../host/runtimeProjection';
import { PiConfigurationSchema, projectPiTurn } from '../piDurableProjection';
import { PiConfigurationSchema, piToolBlueprint, projectPiTurn } from '../piDurableProjection';

const writeFile = {
ref: { source: 'builtin' as const, capabilityId: 'write_file' },
Expand Down Expand Up @@ -99,6 +99,13 @@ function call(content: string, timestamp: number, durationMs?: number): Assistan
};
}

test('stored tool blueprints keep deferred discovery across restarts', () => {
expect(piToolBlueprint({ ...writeFile, deferred: true } as never)).toMatchObject({
deferred: true,
});
expect(piToolBlueprint(writeFile as never)).not.toHaveProperty('deferred');
});

describe('Pi turn projection', () => {
test('preserves upstream loaded tool names for search presentation', () => {
const turn = projectPiTurn({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ describe('durable capability boundary', () => {
capability({
ref: { source: 'builtin', capabilityId: name },
providerName: name,
...(['find_skills', 'install_skill', 'adapt_skill'].includes(name)
? { deferred: true }
: {}),
execute,
}),
),
Expand Down
1 change: 1 addition & 0 deletions src/backend/ai/agent/runtime/pi/piDurableProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const PiToolBlueprintSchema = z.object({
approval: z.enum(['auto', 'ask', 'deny']),
failureGroup: z.string().optional(),
autoApprovalEligible: z.boolean().optional(),
deferred: z.boolean().optional(),
});

const WebToolRouteSchema = z.enum(['client', 'server', 'none']);
Expand Down
6 changes: 1 addition & 5 deletions src/backend/ai/agent/runtime/pi/piDurableTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ type PiDurableToolOptions = {

const BUDGET_REFUSAL = `The turn's ${PI_TOOL_BUDGET.maxToolCalls} tool-call budget is exhausted.`;

const DEFERRED_SKILL_TOOLS = new Set(['find_skills', 'install_skill', 'adapt_skill']);

const codemodeRulesSection = section(
'cherry-codemode',
() =>
Expand Down Expand Up @@ -223,9 +221,7 @@ export function createPiDurableToolExtension(options: PiDurableToolOptions): Ext
},
required: ['value', 'artifacts'],
} as TSchema,
...(tool.ref.source === 'mcp' || DEFERRED_SKILL_TOOLS.has(tool.ref.capabilityId)
? { deferred: true as const }
: {}),
...(tool.ref.source === 'mcp' || tool.deferred ? { deferred: true as const } : {}),
replay: 'unsafe',
async execute(args: unknown, api: ToolExecutionApi, context: Context) {
const output = await execute(tool, RuntimeJsonValueSchema.parse(args), api, context);
Expand Down
2 changes: 2 additions & 0 deletions src/backend/ai/agent/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ export type RuntimeTool = {
autoApprovalEligible?: boolean;
/** Refuse before approval until a trusted instruction section with this key is in context. */
requiredInstructions?: { key: string; refusal: string };
/** Low-frequency tools are found through tool_search instead of sent with every request. */
deferred?: boolean;
execute(call: RuntimeToolCall): Promise<RuntimeToolResult>;
};

Expand Down
46 changes: 40 additions & 6 deletions src/backend/ai/agent/tools/__tests__/builtInToolSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,22 +54,43 @@ describe('createSystemCapabilitySource', () => {
'edit_file',
'library_search_files',
'library_read_file',
'library_list_folders',
'library_move_item',
'library_rename_item',
'library_trash_item',
'library_list_trash',
'library_restore_item',
'read_file',
'write_file',
]);
});

test('requires explicit approval for every file-library call and honors the group deny-list', async () => {
test('applies the shared read/mutation approval to file-library tools, defers management, and honors the group deny-list', async () => {
const tools = await resolve({});
for (const name of ['library_search_files', 'library_read_file']) {
const reads = ['library_search_files', 'library_read_file'];
const management = {
library_list_folders: 'auto',
library_move_item: 'ask',
library_rename_item: 'ask',
library_trash_item: 'ask',
library_list_trash: 'auto',
library_restore_item: 'ask',
};
const names = [...reads, ...Object.keys(management)];
for (const name of reads) {
const tool = tools.find((item) => item.providerName === name);
expect(tool).toMatchObject({ approval: 'auto', autoApprovalEligible: true });
expect(tool?.deferred).toBeUndefined();
}
for (const [name, approval] of Object.entries(management)) {
expect(tools.find((tool) => tool.providerName === name)).toMatchObject({
approval: 'ask',
autoApprovalEligible: false,
approval,
autoApprovalEligible: true,
deferred: true,
});
}
const disabled = await resolve({ disabledCapabilities: ['fileLibrary'] });
expect(capabilityIds(disabled)).not.toContain('library_search_files');
expect(capabilityIds(disabled)).not.toContain('library_read_file');
for (const name of names) expect(capabilityIds(disabled)).not.toContain(name);
expect(capabilityIds(disabled)).toContain('read_file');
});

Expand Down Expand Up @@ -119,6 +140,12 @@ describe('createSystemCapabilitySource', () => {
'edit_file',
'library_search_files',
'library_read_file',
'library_list_folders',
'library_move_item',
'library_rename_item',
'library_trash_item',
'library_list_trash',
'library_restore_item',
'read_file',
'write_file',
]);
Expand Down Expand Up @@ -164,6 +191,12 @@ describe('createSystemCapabilitySource', () => {
'edit_file',
'library_search_files',
'library_read_file',
'library_list_folders',
'library_move_item',
'library_rename_item',
'library_trash_item',
'library_list_trash',
'library_restore_item',
'read_file',
'write_file',
]);
Expand Down Expand Up @@ -612,6 +645,7 @@ const SERVICES: SystemCapabilityServices = {
model: { getById: jest.fn() },
preference: { get: jest.fn() },
webSearch: { fetchUrls: jest.fn(), searchKeywords: jest.fn() },
trash: { entries: { list: jest.fn() }, restore: jest.fn() },
} as unknown as SystemCapabilityServices;

function dependencies(scenario: Scenario): Partial<SystemCapabilitySourceDependencies> {
Expand Down
Loading
Loading