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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

extension RustCoreBridge {
/// One cached pasted image sent to `agent.attachments.retentionPlan`.
struct AgentAttachmentEntry: Encodable, Sendable {
let path: String
let modifiedMs: Int64?
let abandoned: Bool
}

/// Files Core says to delete; Swift only lists the cache and performs the removal.
struct AgentAttachmentRetentionPlan: Decodable, Sendable {
struct Decision: Decodable, Sendable {
let path: String
let reason: String
}
let delete: [Decision]
let keep: [Decision]
/// Images to write to a new ledger before deleting anything (empty once a ledger exists).
let seed: [String]
}

func agentAttachmentRetentionPlan(
nowMs: Int64, entries: [AgentAttachmentEntry], acceptedReferences: [String], ledgerPresent: Bool
) throws -> AgentAttachmentRetentionPlan {
struct Request: Encodable {
let nowMs: Int64
let entries: [AgentAttachmentEntry]
let acceptedReferences: [String]
let ledgerPresent: Bool
let caseInsensitive = false
}
let result: Result<AgentAttachmentRetentionPlan, CoreCallError> = executeResult(
command: "agent.attachments.retentionPlan",
payload: Request(nowMs: nowMs, entries: entries, acceptedReferences: acceptedReferences,
ledgerPresent: ledgerPresent)
)
return try result.get()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -200,23 +200,75 @@ extension AppModel {
}.prefix(20))
}

/// Caches pasted images and the ledger of the ones a conversation accepted.
/// Both live in Caches (never the app bundle); Core decides what may be deleted.
private var agentPastedImageDirectory: URL {
services.fileStorage.cacheDirectory().appendingPathComponent("AgentPastedImages", isDirectory: true)
}

private var agentAcceptedAttachmentLedger: URL {
services.fileStorage.cacheDirectory().appendingPathComponent("AgentAcceptedAttachments.json", isDirectory: false)
}

private func acceptedAgentAttachmentPaths() -> [String] {
let storage = services.fileStorage
guard storage.fileExists(at: agentAcceptedAttachmentLedger),
let data = try? storage.readData(from: agentAcceptedAttachmentLedger, options: []),
let paths = try? JSONDecoder().decode([String].self, from: data) else { return [] }
return paths
}

/// `.atomic` writes a temporary sibling and renames it over the ledger.
private func writeAgentAcceptedAttachmentLedger(_ paths: [String]) throws {
let storage = services.fileStorage
try storage.createDirectory(at: agentAcceptedAttachmentLedger.deletingLastPathComponent(), withIntermediateDirectories: true)
try storage.writeData(try JSONEncoder().encode(paths), to: agentAcceptedAttachmentLedger, options: .atomic)
}

/// Remembers the cached images an accepted prompt refers to, so a later
/// paste never deletes an image a conversation still points at.
func recordAcceptedAgentAttachments(_ files: [AgentFileReference]) {
let storage = services.fileStorage
let directory = agentPastedImageDirectory.standardizedFileURL.path + "/"
let accepted = files.map(\.url.path).filter { $0.hasPrefix(directory) }
// Without a ledger the cache has not been migrated; the next paste seeds it, accepted images included.
guard !accepted.isEmpty, storage.fileExists(at: agentAcceptedAttachmentLedger) else { return }
var paths = acceptedAgentAttachmentPaths().filter { storage.fileExists(at: URL(fileURLWithPath: $0)) }
for path in accepted where !paths.contains(path) { paths.append(path) }
do {
try writeAgentAcceptedAttachmentLedger(paths)
} catch { services.logSink?.emit(AgentImagesCleanupFailed(count: 1, error: error)) }
}

/// Stores a pasted image so it can be attached like any local file. Agents
/// read attachments as ACP resource links, so the image must outlive the
/// paste; it lives in Caches (never the app bundle) and is pruned after a week.
/// paste; Core's retention plan keeps every image an accepted prompt references.
func saveAgentPastedImage(_ png: Data) throws -> URL {
let storage = services.fileStorage
let directory = storage.cacheDirectory().appendingPathComponent("AgentPastedImages", isDirectory: true)
let directory = agentPastedImageDirectory
try storage.createDirectory(at: directory, withIntermediateDirectories: true)
let cutoff = Date().addingTimeInterval(-7 * 24 * 3600)
let entries = storage.listDirectory(at: directory).map { file in
RustCoreBridge.AgentAttachmentEntry(
path: file.path,
modifiedMs: storage.metadata(for: file)?.modificationDate.map { Int64($0.timeIntervalSince1970 * 1000) },
abandoned: false)
}
var cleanupCount: Int64 = 0
var cleanupError: (any Error)?
for file in storage.listDirectory(at: directory)
where (storage.metadata(for: file)?.modificationDate ?? .distantFuture) < cutoff {
do { try storage.removeItem(at: file) }
catch where (error as NSError).domain == NSCocoaErrorDomain && [NSFileNoSuchFileError, NSFileReadNoSuchFileError].contains((error as NSError).code) {
// log-exempt: 预期的缺失:过期图片已由其他清理移除
} catch { cleanupCount += 1; cleanupError = error }
}
do {
let plan = try RustCoreBridge(coreLogSink: services.logSink).agentAttachmentRetentionPlan(
nowMs: Int64(Date().timeIntervalSince1970 * 1000), entries: entries,
acceptedReferences: acceptedAgentAttachmentPaths(),
ledgerPresent: storage.fileExists(at: agentAcceptedAttachmentLedger))
// A cache that predates the ledger is seeded first; if that fails nothing is deleted.
if !storage.fileExists(at: agentAcceptedAttachmentLedger) { try writeAgentAcceptedAttachmentLedger(plan.seed) }
for decision in plan.delete {
do { try storage.removeItem(at: URL(fileURLWithPath: decision.path)) }
catch where (error as NSError).domain == NSCocoaErrorDomain && [NSFileNoSuchFileError, NSFileReadNoSuchFileError].contains((error as NSError).code) {
// log-exempt: 预期的缺失:过期图片已由其他清理移除
} catch { cleanupCount += 1; cleanupError = error }
}
} catch { cleanupCount += 1; cleanupError = error }
if let cleanupError { services.logSink?.emit(AgentImagesCleanupFailed(count: cleanupCount, error: cleanupError)) }
let url = directory.appendingPathComponent(AgentPastedImage.fileName(), isDirectory: false)
try storage.writeData(png, to: url, options: .atomic)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ struct AgentConversationView: View {
onFindFiles: { await model.agentFileMentions(matching: $0) },
fileMentionRevision: model.workspaceSnapshotID,
onSavePastedImage: { try model.saveAgentPastedImage($0) },
onAttachmentsAccepted: { model.recordAcceptedAgentAttachments($0) },
availableAgents: selectableAgents,
historyOnly: !isEditor && model.agentChatsInEditor,
showsChatActions: !isEditor,
Expand Down Expand Up @@ -255,6 +256,7 @@ struct AgentConfiguredConversationView: View {
var onFindFiles: ((String) async -> [AgentFileMention])? = nil
var fileMentionRevision: UUID? = nil
var onSavePastedImage: ((Data) throws -> URL)? = nil
var onAttachmentsAccepted: (([AgentFileReference]) -> Void)? = nil
var availableAgents: [AgentOption]? = nil
var historyOnly = false
var showsChatActions = true
Expand Down Expand Up @@ -284,6 +286,7 @@ struct AgentConfiguredConversationView: View {
onFindFiles: onFindFiles,
fileMentionRevision: fileMentionRevision,
onSavePastedImage: onSavePastedImage,
onAttachmentsAccepted: onAttachmentsAccepted,
historyOnly: historyOnly, showsChatActions: showsChatActions, chatsInEditor: chatsInEditor,
onToggleLocation: onToggleLocation, onFocusEditor: onFocusEditor,
onOpenNewEditorTab: onOpenNewEditorTab, onSessionChanged: onSessionChanged,
Expand Down Expand Up @@ -390,6 +393,7 @@ private struct AgentConnectionView: View {
var onFindFiles: ((String) async -> [AgentFileMention])? = nil
var fileMentionRevision: UUID? = nil
var onSavePastedImage: ((Data) throws -> URL)? = nil
var onAttachmentsAccepted: (([AgentFileReference]) -> Void)? = nil
var historyOnly = false
var showsChatActions = true
var chatsInEditor = false
Expand Down Expand Up @@ -674,7 +678,7 @@ private struct AgentConnectionView: View {
isBlocked: feature.isCreatingSession
|| feature.selectedConversation?.isLoading == true
|| feature.connectionState != .ready,
onSend: { try feature.send($0, files: $1) },
onSend: { try feature.send($0, files: $1); onAttachmentsAccepted?($1) },
onCancel: { feature.cancel() },
onSelectAgent: onSelectAgent,
onOpenSettings: onOpenSettings,
Expand Down
41 changes: 41 additions & 0 deletions macos/Tests/LitheTests/AgentAttachmentRetentionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation
import Testing
@testable import Lithe

@Suite("Agent attachment retention")
struct AgentAttachmentRetentionTests {
/// Retention rules are Core's; the shared fixture is the contract both platforms follow.
@Test(.enabled(if: RustCoreBridge().isAvailable, "Requires the linked Rust Core integration library"))
func bridgeReturnsTheSharedRetentionPlans() throws {
struct Fixture: Decodable {
struct Case: Decodable {
struct Request: Decodable {
struct Entry: Decodable { let path: String; let modifiedMs: Int64?; let abandoned: Bool? }
let nowMs: Int64
let entries: [Entry]
let acceptedReferences: [String]?
let ledgerPresent: Bool
let caseInsensitive: Bool?
}
let name: String
let request: Request
let expected: RustCoreBridge.AgentAttachmentRetentionPlan
}
let cases: [Case]
}
let url = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/agent/attachment-retention-v1.json")
let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url))
for item in fixture.cases where item.request.caseInsensitive != true {
let plan = try RustCoreBridge().agentAttachmentRetentionPlan(
nowMs: item.request.nowMs,
entries: item.request.entries.map { .init(path: $0.path, modifiedMs: $0.modifiedMs, abandoned: $0.abandoned ?? false) },
acceptedReferences: item.request.acceptedReferences ?? [],
ledgerPresent: item.request.ledgerPresent)
#expect(plan.seed == item.expected.seed, "\(item.name)")
#expect(plan.delete.map(\.path) == item.expected.delete.map(\.path), "\(item.name)")
#expect(plan.delete.map(\.reason) == item.expected.delete.map(\.reason), "\(item.name)")
#expect(plan.keep.map(\.path) == item.expected.keep.map(\.path), "\(item.name)")
#expect(plan.keep.map(\.reason) == item.expected.keep.map(\.reason), "\(item.name)")
}
}
}
123 changes: 123 additions & 0 deletions rust/lithe-agent-host/src/attachment_retention.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! Retention plan for pasted Agent images.
//!
//! A pasted image is a file the Agent reads later as a resource link, so it must
//! outlive the paste for as long as any conversation still refers to it. Platforms
//! list their pasted-image cache and keep a ledger of the references a conversation
//! accepted; this module decides which files may be deleted. Deleting stays with
//! the platform. Contract: `shared/fixtures/agent/attachment-retention-v1.json`.

use std::collections::HashSet;

use serde::{Deserialize, Serialize};

/// Age after which an unreferenced image is no longer needed (seven days).
pub const RETENTION_MILLISECONDS: i64 = 7 * 24 * 3600 * 1000;

/// One file in the platform's pasted-image cache.
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Entry {
pub path: String,
/// Last modification time in Unix milliseconds; unknown ages are never expired.
#[serde(default)]
pub modified_ms: Option<i64>,
/// True for a paste that was abandoned or failed before any conversation accepted it.
#[serde(default)]
pub abandoned: bool,
}

/// Payload of `agent.attachments.retentionPlan`. Pure: no files are read or written.
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Request {
pub now_ms: i64,
pub entries: Vec<Entry>,
/// Paths a conversation has accepted, from the platform's ledger.
#[serde(default)]
pub accepted_references: Vec<String>,
/// Whether the platform's ledger file exists. Only its existence matters, never
/// its contents: a missing ledger means the cache predates the ledger, so every
/// image already cached is seeded as accepted instead of being expired.
pub ledger_present: bool,
/// Compare paths without case (Windows); separators are always normalized.
#[serde(default)]
pub case_insensitive: bool,
}

/// Why a file is deleted or kept; stable snake_case codes for logs and tests.
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Reason {
Referenced,
Abandoned,
Expired,
Recent,
}

/// One decision for one cache entry.
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Decision {
pub path: String,
pub reason: Reason,
}

/// Files the platform should delete and files it must keep.
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Plan {
pub delete: Vec<Decision>,
pub keep: Vec<Decision>,
/// Paths the platform must write to a new ledger before deleting anything;
/// empty whenever the ledger already exists.
pub seed: Vec<String>,
}

fn key(path: &str, case_insensitive: bool) -> String {
let normalized = path.replace('\\', "/");
if case_insensitive {
normalized.to_lowercase()
} else {
normalized
}
}

/// A referenced image is always kept, even when the paste was marked abandoned.
pub fn plan(request: &Request) -> Plan {
let seeded = request
.entries
.iter()
.filter(|entry| !request.ledger_present && !entry.abandoned);
let referenced: HashSet<String> = request
.accepted_references
.iter()
.chain(seeded.clone().map(|entry| &entry.path))
.map(|path| key(path, request.case_insensitive))
.collect();
let cutoff = request.now_ms.saturating_sub(RETENTION_MILLISECONDS);
let mut result = Plan {
delete: Vec::new(),
keep: Vec::new(),
seed: seeded.map(|entry| entry.path.clone()).collect(),
};
for entry in &request.entries {
let reason = if referenced.contains(&key(&entry.path, request.case_insensitive)) {
Reason::Referenced
} else if entry.abandoned {
Reason::Abandoned
} else if entry.modified_ms.is_some_and(|modified| modified < cutoff) {
Reason::Expired
} else {
Reason::Recent
};
let decision = Decision {
path: entry.path.clone(),
reason,
};
match reason {
Reason::Referenced | Reason::Recent => result.keep.push(decision),
Reason::Abandoned | Reason::Expired => result.delete.push(decision),
}
}
result
}
1 change: 1 addition & 0 deletions rust/lithe-agent-host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

pub mod failure_log;

pub mod attachment_retention;
pub mod catalog;
mod claude_desktop;
mod claude_earlier_history;
Expand Down
11 changes: 11 additions & 0 deletions rust/lithe-core/src/agent/attachments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//! Pasted-image retention dispatch. Listing and deleting files belong to the platform.

use crate::protocol::{CoreError, ErrorCode};
use lithe_agent_host::attachment_retention;
use serde_json::Value;

/// Decides which pasted images may be deleted; reads and writes no files.
pub(crate) fn retention_plan(request: attachment_retention::Request) -> Result<Value, CoreError> {
serde_json::to_value(attachment_retention::plan(&request))
.map_err(|issue| CoreError::new(ErrorCode::Unknown, issue.to_string()))
}
1 change: 1 addition & 0 deletions rust/lithe-core/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use std::path::PathBuf;

pub(crate) mod attachments;
pub(crate) mod edits;
pub(crate) mod hooks;
pub(crate) mod instructions;
Expand Down
3 changes: 3 additions & 0 deletions rust/lithe-core/src/protocol/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ pub enum CoreCommand {
AgentEditsReversePlan,
/// Validates an unsaved Hook draft field by field (`agent.hooks.validateDraft`).
AgentHooksValidateDraft,
/// Plans which pasted Agent images may be deleted (`agent.attachments.retentionPlan`).
AgentAttachmentsRetentionPlan,
/// Budgets diff prefixes before crossing the native boundary (`ai.commitInputBudget`).
AiCommitInputBudget,
/// Plans a credential-free commit message request (`ai.planCommit`).
Expand Down Expand Up @@ -828,6 +830,7 @@ impl CoreCommand {
"agent.installCli" => Some(Self::AgentInstallCli),
"agent.edits.reversePlan" => Some(Self::AgentEditsReversePlan),
"agent.hooks.validateDraft" => Some(Self::AgentHooksValidateDraft),
"agent.attachments.retentionPlan" => Some(Self::AgentAttachmentsRetentionPlan),
"ai.commitInputBudget" => Some(Self::AiCommitInputBudget),
"ai.planCommit" => Some(Self::AiPlanCommit),
"ai.decodeCommitMessage" => Some(Self::AiDecodeCommitMessage),
Expand Down
5 changes: 5 additions & 0 deletions rust/lithe-core/src/runtime/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse {
CoreCommand::AgentHooksValidateDraft => {
agent_response(id, parsed.payload, crate::agent::hooks::validate_draft)
}
CoreCommand::AgentAttachmentsRetentionPlan => agent_response(
id,
parsed.payload,
crate::agent::attachments::retention_plan,
),
CoreCommand::McpParseConfiguration => {
match crate::mcp::parse_configuration(parsed.payload) {
Ok(data) => CoreResponse::success(id, data),
Expand Down
Loading
Loading