diff --git a/AgentKit b/AgentKit index a4fd480c..bd943176 160000 --- a/AgentKit +++ b/AgentKit @@ -1 +1 @@ -Subproject commit a4fd480cdff40ee793b0ca3b239beba4d18d858d +Subproject commit bd943176ca40bc340f9e850f60d6bfc71098decc diff --git a/FrameworkTests/AssistantChatCollaborationReviewTests.swift b/FrameworkTests/AssistantChatCollaborationReviewTests.swift index cc353b19..d7e3fee8 100644 --- a/FrameworkTests/AssistantChatCollaborationReviewTests.swift +++ b/FrameworkTests/AssistantChatCollaborationReviewTests.swift @@ -226,9 +226,42 @@ struct AssistantChatCollaborationReviewTests { let target = fixture.addTarget() target.messages = [ChatMessage(role: .assistant, text: String(repeating: "x", count: 20_000))] let read = try await fixture.coordinator.readChat(id: target.id, cursor: nil) - #expect(read.objectValue?["truncated"]?.boolValue == true) + #expect(read.objectValue?["truncated"]?.boolValue == false) let text = try #require(read.objectValue?["messages"]?.arrayValue?.first?.objectValue?["text"]?.stringValue) - #expect(text.count == 8_000) + #expect(text.count == 20_000) + } + + @Test func readChatContinuesAtTheFirstUnreturnedWholeLineWithoutCapturingPages() async throws { + let fixture = ChatReviewFixture() + let target = fixture.addTarget() + let original = String(repeating: String(repeating: "\"", count: 400) + "\n", count: 200) + "last>" + target.messages = [ChatMessage(role: .assistant, text: original)] + var cursor: String? + var received = "" + var pages = 0 + repeat { + let page = try await fixture.coordinator.readChat(id: target.id, cursor: cursor) + var raw = AgentToolResult(callID: "call", content: page.encodedString) + raw.hasBoundedModelContent = true + let projected = await AgentToolOutputProjection().project( + raw, + invocation: .init(runID: UUID(), call: .init(id: "call", name: "read_chat", arguments: .object([:])))) + let model = try #require(projected.modelContent) + #expect(model.utf8.count <= 65_536) + #expect(projected.outputCapture == nil) + let framed = AgentToolResult(callID: "call", content: model, metadata: ["untrusted": .bool(true)]) + let payload = try #require(framed.untrustedPayload) + let fields = try #require(AgentJSONValue.decode(Data(payload.utf8)).objectValue) + received += + fields["messages"]?.arrayValue?.compactMap { $0.objectValue?["text"]?.stringValue }.joined() ?? "" + let next = fields["next_cursor"]?.stringValue + #expect(next == nil || next != cursor) + cursor = next + pages += 1 + } while cursor != nil && pages < 10 + #expect(cursor == nil) + #expect(pages > 1) + #expect(received == original) } @Test func listingBatchesMixedCreatorRelationsOncePerPage() async throws { diff --git a/FrameworkTests/AssistantTranscriptPresentationTests.swift b/FrameworkTests/AssistantTranscriptPresentationTests.swift index 66b58989..ac03d437 100644 --- a/FrameworkTests/AssistantTranscriptPresentationTests.swift +++ b/FrameworkTests/AssistantTranscriptPresentationTests.swift @@ -177,7 +177,7 @@ struct AssistantTranscriptPresentationTests { #expect(preview.full == full) #expect(preview.displayed.hasPrefix("line 0:")) #expect(preview.displayed.contains("line 249:")) - #expect(preview.displayed.count <= AgentToolPresentation.displayCharacterLimit) + #expect(preview.displayed.utf8.count <= AgentToolPresentation.displayByteLimit) #expect(preview.displayed.split(separator: "\n", omittingEmptySubsequences: false).count <= 120) } diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 281b8009..ff5432fc 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -49103,47 +49103,6 @@ } } }, - "Output truncated" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ausgabe abgeschnitten" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sortie tronquée" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "出力が切り詰められました" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "출력이 잘림" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "输出已截断" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "輸出已截斷" - } - } - } - }, "Output will appear here" : { "extractionState" : "manual", "localizations" : { @@ -86230,71 +86189,6 @@ } } }, - "[… %lld characters omitted …]" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "[… %lld Zeichen ausgelassen …]" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "[… %lld Zeichen ausgelassen …]" - } - } - } - } - }, - "fr" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "[… %lld caractère omis …]" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "[… %lld caractères omis …]" - } - } - } - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "[… %lld文字省略 …]" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "[… %lld자 생략 …]" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "[…%lld字符省略…]" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "[…%lld字元省略…]" - } - } - } - }, "`%@` can irreversibly destroy data or state." : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift b/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift index 72757a77..e767fa03 100644 --- a/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift +++ b/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift @@ -291,22 +291,29 @@ public final class AssistantChatCoordinator: AgentChatModelCatalog, AgentChatCoo throw AgentToolError.invalidArguments("The chat does not exist.") } configure(conversation) - let offset = try Self.offset(cursor) + let parts = cursor?.split(separator: ":", omittingEmptySubsequences: false).map(String.init) ?? [] + guard parts.count <= 2 else { throw AgentToolError.invalidArguments("The chat cursor is invalid.") } + let offset = try Self.offset(parts.first) + let lineOffset = try parts.count == 2 ? Self.offset(parts[1]) : 1 + guard lineOffset > 0 else { throw AgentToolError.invalidArguments("The chat cursor is invalid.") } let visible = conversation.messages guard offset <= visible.count else { throw AgentToolError.invalidArguments("The chat cursor is invalid.") } let page = Array(visible.dropFirst(offset).prefix(20)) - var budget = 40_000 - var wasTruncated = false - let messages = page.map { message -> AgentJSONValue in - let allowed = max(0, min(8_000, budget)) - let text = String(message.text.prefix(allowed)) - if text.count < message.text.count { wasTruncated = true } - budget -= text.count + if lineOffset > 1 { + guard let first = page.first, lineOffset <= AgentTruncateResult.logicalLines(first.text).count else { + throw AgentToolError.invalidArguments("The chat cursor is invalid.") + } + } + let messages = page.enumerated().map { index, message -> AgentJSONValue in + let start = index == 0 ? lineOffset : 1 + let text = + start == 1 ? message.text : AgentTruncateResult.logicalLines(message.text).dropFirst(start - 1).joined() return .object([ "role": .string(message.role == .user ? "user" : "assistant"), "text": .string(text), + "line_offset": .number(Double(start)), "agent_origin": .bool(message.origin != nil), ]) } @@ -320,9 +327,10 @@ public final class AssistantChatCoordinator: AgentChatModelCatalog, AgentChatCoo var result = chatObject(conversation) result["messages"] = .array(messages) result["message_count"] = .number(Double(page.count)) + result["message_offset"] = .number(Double(offset)) result["tool_summaries"] = .array(toolRows) result["deliveries"] = .array(receipts.suffix(20).map(receiptJSON)) - result["truncated"] = .bool(wasTruncated) + result["truncated"] = .bool(false) let next = offset + page.count if next < visible.count { result["next_cursor"] = .string(String(next)) } return .object(result) diff --git a/Frameworks/TermindApplication/Agent/AssistantConversationOperations.swift b/Frameworks/TermindApplication/Agent/AssistantConversationOperations.swift index ab268221..d9e95c2b 100644 --- a/Frameworks/TermindApplication/Agent/AssistantConversationOperations.swift +++ b/Frameworks/TermindApplication/Agent/AssistantConversationOperations.swift @@ -79,13 +79,7 @@ public final class AssistantConversationOperations { } try await repository.deleteConversation(conversation.id) await conversation.secretBroker.discardAll() - do { - try await AgentScratchWorkspace( - conversationID: conversation.id, - applicationName: TermindAgentPolicy.scratchDirectoryName - ).removeScope() - return (true, nil) - } catch { return (true, error.localizedDescription) } + return (true, nil) } public func fork(_ source: ConversationModel, at messageID: UUID, title: String) async throws -> ConversationModel? @@ -108,11 +102,6 @@ public final class AssistantConversationOperations { state: $0.state.rawValue, result: $0.result, review: $0.review) }, lastRunState: .completed, title: "", metadata: fork.metadata) do { - try await Task.detached(priority: .utility) { - try AgentScratchWorkspace.copyScope( - from: source.id, to: fork.id, - applicationName: TermindAgentPolicy.scratchDirectoryName) - }.value try await repository.createConversation(stored) do { try modelSelections?.persistFork(fork) @@ -122,9 +111,6 @@ public final class AssistantConversationOperations { } return fork } catch { - try? await AgentScratchWorkspace( - conversationID: fork.id, applicationName: TermindAgentPolicy.scratchDirectoryName - ).removeScope() throw error } } diff --git a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift index 6ce28aae..4f08d418 100644 --- a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift +++ b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift @@ -558,8 +558,7 @@ public final class AssistantExecutionController { // only records events would leave this off — see `AgentStreamPacing`. configuration.streamPacing = .animated configuration.outputProjection = AgentToolOutputProjection( - workspace: AgentScratchWorkspace( - conversationID: conversation.id, applicationName: TermindAgentPolicy.scratchDirectoryName)) + workspace: AgentScratchWorkspace.shared(applicationName: TermindAgentPolicy.scratchDirectoryName)) configuration.contextSnapshot = { _, _ in frozenContext } configuration.contextPipeline = { sessionContext, promptID, planning in var pipeline = AgentContextPipeline.chat( diff --git a/Frameworks/TermindApplication/Agent/TermindAgentPolicy.swift b/Frameworks/TermindApplication/Agent/TermindAgentPolicy.swift index 9b8d0f84..3b055365 100644 --- a/Frameworks/TermindApplication/Agent/TermindAgentPolicy.swift +++ b/Frameworks/TermindApplication/Agent/TermindAgentPolicy.swift @@ -44,8 +44,12 @@ nonisolated public enum TermindAgentPolicy { context block lists only surface-specific overrides, not the complete catalog. Unlisted tools retain their supplied definitions and policies. terminal_suggest availability does not restrict ssh_exec_run or SFTP tools; tool approval policies and Plan-mode restrictions still apply. - The scratch_* tools are a private staging directory that persists across this conversation and - is not shown to the user, so put working copies and drafts there rather than in your replies. + Scratch is a mandatory, device-local workspace shared by all conversations, not a conversation-private + directory. Choose explicit task-specific relative paths, such as investigations/nginx/notes.md, + for every read and write. Check existing files before overwriting them. Missing parent directories + are created automatically. Use scratch_copy, scratch_move and scratch_replace for staged files. + Automatically captured tool output uses generated /outputs/-.txt paths. Follow the exact + path in the truncation notice with scratch_read or scratch_search; never guess a capture filename. For a remote file you intend to change, scratch_pull, scratch_replace, then scratch_push: the file's contents then never enter this conversation, and the only step the user is asked to approve is the one that reaches their host. Duplicate or rename a staged file with scratch_copy diff --git a/Frameworks/TermindApplication/AgentSkillLibrary.swift b/Frameworks/TermindApplication/AgentSkillLibrary.swift index 105a01de..3f50c839 100644 --- a/Frameworks/TermindApplication/AgentSkillLibrary.swift +++ b/Frameworks/TermindApplication/AgentSkillLibrary.swift @@ -117,48 +117,49 @@ public nonisolated enum AgentSkillLibrary { non-PTY mode or let the user interact in their terminal rather than retrying blindly. Foreground interjection can hand execution to background but cannot reopen closed stdin. - ## Plan output before running: use > for verbose commands + ## Output and automatic capture - ssh_exec_output(handle) returns an immediate, cumulative snapshot, not a new-output cursor. - Each stream keeps only its latest 256 KiB, prefixed with `[N chars were truncated]` when - older output was discarded. Re-reading cannot recover it. For builds, long logs or output - that must remain complete, choose an authorized remote log path before starting. - Use shell command substitution to generate a timestamped path, print it, then redirect: + ssh_exec_run and ssh_exec_output return combined output in stdout/stderr callback-arrival + order, plus handle, status, exit_code, signal, error and stdin_closed. ssh_exec_output is an + immediate cumulative snapshot, not a consuming cursor. Keep the handle and check completion. + Model responses keep a tail of whole logical lines within 64 KiB (65,536 UTF-8 bytes) and + 2,000 lines. They never cut a line in half; a single oversized line may be omitted entirely. + An unterminated final line can still be an interactive prompt. + + On truncation, read the exact generated /outputs/ssh-exec-.txt path from the result's + notice. scratch_read(path, offset, limit) pages the saved output; scratch_search(query, path) + finds matching lines. The running command updates one cumulative file. A truncated ssh_exec_output + response saves its own snapshot as /outputs/ssh-exec-output-.txt. + AgentKit marks truncation as [Showing lines - of . Full output: ]. + Without a complete saved copy, the same marker omits the Full output clause. + A snapshot capture covers the reported output, not future output. Check capture_error: + file/workspace quotas or write errors mean a complete saved copy is unavailable. Do not claim + that truncation can always be recovered when capture failed. Captures are local to this app, + not remote paths, and are not automatically deleted when a conversation is deleted. + + Scratch is shared across conversations. Choose task-specific relative paths for your own + files, inspect before overwriting, and follow returned paths rather than inventing filenames. + Parent directories are created automatically; do not use absolute paths or '..'. + + For output expected to exceed scratch quotas, choose an authorized remote log path before + starting and redirect there. For example, in one ssh_exec_run call: ```sh - log="/tmp/task-$(date +%Y%m%d-%H%M%S).log" + log="$(mktemp /tmp/task.XXXXXXXX)" printf 'Log: %s\\n' "$log" command > "$log" 2>&1 ``` - To keep stdout data and stderr diagnostics separate: - - ```sh - base="/tmp/task-$(date +%Y%m%d-%H%M%S)" - printf 'Stdout: %s.out\\nStderr: %s.err\\n' "$base" "$base" - command > "$base.out" 2> "$base.err" - ``` - - - Run each block in a single ssh_exec_run call. Shell variables do not persist between calls. - Reuse the printed path for later reads; do not run date again to reconstruct it. - - `>` creates or truncates; `>>` appends. Use a new task-specific path or confirm overwriting - existing data. Quote paths. `2>&1` must follow `>` to capture both streams in that file. - - Redirection runs on the remote host and does not background the command. Use - run_in_background=true for long-running work; do not add shell `&` merely to return early. - Keep the command handle to check its status even when stdout and stderr are redirected. - - Inspect a bounded slice with `tail -n 100` using the printed path, or read selected ranges with - sftp_read(host_id, path, offset, limit). Follow its next_offset for another page; one response - is not necessarily the whole file. Do not dump a large log back with cat, tail -f, or tee. - Report the remote log path so the user can retrieve the full artifact through SFTP. - For input-driven commands, keep needed prompts visible: they may be on stderr, and redirecting - both streams can hide them. Never log secrets; redirection does not sanitize command output. + Use the printed remote path with sftp_read or a bounded `tail -n 100`. Shell variables do + not persist between calls. Redirection is optional, is not backgrounding, and does not + sanitize output. Never log secrets; do not redirect prompts needed for interactive input. + External MCP clients receive their own full snapshots, without assistant scratch captures. ## stdin workflow 1. Start the intended consumer with ssh_exec_run(..., run_in_background=true). Keep its returned handle; do not start another copy just to supply input. - 2. Use ssh_exec_output(handle) to inspect stdout, stderr, status and stdin_closed. A running + 2. Use ssh_exec_output(handle) to inspect output, status and stdin_closed. A running status alone does not prove the program is waiting for input. Follow its actual protocol or prompt; a pipe consumer may need data immediately without printing a prompt. 3. Send input with ssh_exec_stdin using the same handle. Choose the text or secret flow diff --git a/Frameworks/TermindPresentation/AgentToolDetailPreview.swift b/Frameworks/TermindPresentation/AgentToolDetailPreview.swift index 51a0791f..d0ac4159 100644 --- a/Frameworks/TermindPresentation/AgentToolDetailPreview.swift +++ b/Frameworks/TermindPresentation/AgentToolDetailPreview.swift @@ -3,15 +3,17 @@ import Foundation /// A whole-card budget, not one budget per block. Full values remain untouched for expansion. nonisolated public struct AgentToolDetailPreview: Equatable, Sendable { - public static let characterLimit = 600 + public static let byteLimit = AgentTruncateLimits.maximumBytes public static let rowLimit = 5 public let items: [AgentToolDetail.Item] public let fullItems: [AgentToolDetail.Item] public var isShortened: Bool { items != fullItems } - public init(_ detail: AgentToolDetail) { + public init( + _ detail: AgentToolDetail, strategy: AgentTruncateStrategy = .head, preservesFirstText: Bool = false + ) { fullItems = detail.items - var budget = Budget() + var budget = Budget(strategy: strategy, preservesFirstText: preservesFirstText) items = budget.project(detail.items) } @@ -42,57 +44,41 @@ nonisolated public struct AgentToolDetailPreview: Equatable, Sendable { } private struct Budget { - var characters = AgentToolDetailPreview.characterLimit var rows = AgentToolDetailPreview.rowLimit - var hasRoom: Bool { characters > 0 && rows > 0 } - - mutating func take(_ value: String) -> String { - guard characters > 0 else { return "" } - let result = value.count > characters ? String(value.prefix(max(0, characters - 1))) + "…" : value - characters -= result.count - return result - } - - mutating func optional(_ value: String?) -> String? { - value.map { take($0) } - } + var bytes = AgentToolDetailPreview.byteLimit + var strategy: AgentTruncateStrategy + var preservesFirstText: Bool mutating func project(_ items: [AgentToolDetail.Item]) -> [AgentToolDetail.Item] { var result: [AgentToolDetail.Item] = [] for item in items { - guard hasRoom else { break } switch item { - case .field(var field): - rows -= 1 - field.label = take(field.label) - field.value = take(field.value) - result.append(.field(field)) - case .message(let value, let tint): - rows -= 1 - result.append(.message(take(value), tint)) + case .field, .message: + // Command metadata and notices are never part of the body budget. + result.append(item) case .text(var block): - rows -= 1 - block.title = optional(block.title) - block.text = take(block.text) - result.append(.text(block)) - case .list(var list): - list.title = optional(list.title) - list.rows = projectRows(list.rows) - if list.rows.isEmpty { - rows -= 1 - list.emptyMessage = optional(list.emptyMessage) + if preservesFirstText { + preservesFirstText = false + result.append(item) + continue } + let selected = AgentTruncateResult.apply( + block.text, strategy: strategy, + limits: .init(bytes: bytes, lines: max(0, rows))) + rows -= selected.returnedLines + bytes -= selected.content.utf8.count + block.text = selected.content + if !block.text.isEmpty { result.append(.text(block)) } + case .list(var list): + list.rows = projectRows(list.rows) result.append(.list(list)) case .groupedList(var list): - list.summary = take(list.summary) - var sections: [AgentToolDetail.ListSection] = [] - for var section in list.sections { - guard hasRoom else { break } - section.title = take(section.title) + list.sections = list.sections.compactMap { section in + guard rows > 0 else { return nil } + var section = section section.rows = projectRows(section.rows) - sections.append(section) + return section } - list.sections = sections result.append(.groupedList(list)) } } @@ -101,18 +87,13 @@ nonisolated public struct AgentToolDetailPreview: Equatable, Sendable { mutating func projectRows(_ values: [AgentToolDetail.ListRow]) -> [AgentToolDetail.ListRow] { var result: [AgentToolDetail.ListRow] = [] - for var row in values { - guard hasRoom else { break } - rows -= 1 - row.title = take(row.title) - row.subtitle = optional(row.subtitle) - row.detail = optional(row.detail) - row.badges = row.badges.compactMap { badge in - guard characters > 0 else { return nil } - return take(badge) - } - result.append(row) + for value in values.prefix(max(0, rows)) { + let size = AgentToolDetailPreview.rowText(value).utf8.count + guard size <= bytes else { break } + result.append(value) + bytes -= size } + rows -= result.count return result } } diff --git a/Frameworks/TermindPresentation/AgentToolPresentation.swift b/Frameworks/TermindPresentation/AgentToolPresentation.swift index 49aaa01c..26a33187 100644 --- a/Frameworks/TermindPresentation/AgentToolPresentation.swift +++ b/Frameworks/TermindPresentation/AgentToolPresentation.swift @@ -9,7 +9,7 @@ nonisolated public enum AgentToolPresentation { case secondary, warning, success, failure } - public static let displayCharacterLimit = 8_192 + public static let displayByteLimit = AgentTruncateLimits.maximumBytes public static let displayLineLimit = 120 private static let chatCollaborationToolNames: Set = [ @@ -35,7 +35,7 @@ nonisolated public enum AgentToolPresentation { } public static func showsOutputTruncatedNotice(for card: AgentToolCard) -> Bool { - card.result?.isTruncated == true && !isLocalPaneTool(card.descriptor, named: "read_pane") + truncationNotice(for: card) != nil } private static func isLocalPaneTool(_ descriptor: AgentToolDescriptor, named name: String) -> Bool { @@ -477,59 +477,23 @@ nonisolated public enum AgentToolPresentation { /// it for Copy, while the Text view only receives the small preview. public static func preview( _ full: String, - characterLimit: Int = displayCharacterLimit, + byteLimit: Int = displayByteLimit, lineLimit: Int = displayLineLimit ) -> OutputPreview { - let lineCount = full.reduce(1) { $1 == "\n" ? $0 + 1 : $0 } - guard full.count > characterLimit || lineCount > lineLimit else { - return OutputPreview( - displayed: full, full: full, isShortened: false, omittedCharacters: 0 - ) - } - - // Five eighths at the front and three eighths at the back keeps the - // command and initial diagnostics while retaining the final failure. - // Leave room for the marker itself; a final tightening pass below - // makes the bound exact even when the omitted count has many digits. - let contentCharacterLimit = max(2, characterLimit - min(80, characterLimit / 4)) - let headCharacters = max(1, contentCharacterLimit * 5 / 8) - let tailCharacters = max(1, contentCharacterLimit - headCharacters) - let contentLineLimit = max(2, lineLimit - 1) - let headLines = max(1, contentLineLimit * 5 / 8) - let tailLines = max(1, contentLineLimit - headLines) - - let headByCharacters = full.index( - full.startIndex, - offsetBy: min(headCharacters, full.count) - ) - let tailByCharacters = full.index( - full.endIndex, - offsetBy: -min(tailCharacters, full.count) - ) - let headByLines = boundaryAfterLines(headLines, in: full) - let tailByLines = boundaryBeforeLastLines(tailLines, in: full) - var headEnd = min(headByCharacters, headByLines) - var tailStart = max(tailByCharacters, tailByLines) - - // The limits above normally leave a gap. Keep the fallback defensive - // for tiny custom limits used by unit tests. - tailStart = max(tailStart, headEnd) - var displayed = renderedPreview(full, headEnd: headEnd, tailStart: tailStart) - while displayed.count > characterLimit, headEnd > full.startIndex || tailStart < full.endIndex { - let overflow = displayed.count - characterLimit - let availableHead = full.distance(from: full.startIndex, to: headEnd) - let availableTail = full.distance(from: tailStart, to: full.endIndex) - let dropHead = min(availableHead, max(1, overflow * 5 / 8)) - let dropTail = min(availableTail, max(0, overflow - dropHead)) - headEnd = full.index(headEnd, offsetBy: -dropHead) - tailStart = full.index(tailStart, offsetBy: dropTail) - displayed = renderedPreview(full, headEnd: headEnd, tailStart: tailStart) - } - let omitted = full.distance(from: headEnd, to: tailStart) + let selected = AgentTruncateResult.apply( + full, strategy: .middle, limits: .init(bytes: byteLimit, lines: lineLimit)) return OutputPreview( - displayed: displayed, full: full, - isShortened: true, omittedCharacters: omitted - ) + displayed: selected.content, full: full, isShortened: selected.isTruncated, + omittedCharacters: max(0, full.count - selected.content.count)) + } + + public static func compactStrategy(for descriptor: AgentToolDescriptor) -> AgentTruncateStrategy { + descriptor.target != .mcp && ["ssh_exec_run", "ssh_exec_output"].contains(descriptor.name) ? .tail : .head + } + + public static func truncationNotice(for card: AgentToolCard) -> String? { + guard !isLocalPaneTool(card.descriptor, named: "read_pane") else { return nil } + return card.result?.truncateMarker } private static func approvalArgumentItems( @@ -571,41 +535,6 @@ nonisolated public enum AgentToolPresentation { descriptor.target == .local && chatCollaborationToolNames.contains(descriptor.name) } - private static func renderedPreview( - _ full: String, headEnd: String.Index, tailStart: String.Index - ) -> String { - let omitted = full.distance(from: headEnd, to: tailStart) - let marker = String(localized: "[… \(omitted) characters omitted …]") - return String(full[.. String.Index { - guard count > 0 else { return text.startIndex } - var lines = 1 - var index = text.startIndex - while index < text.endIndex { - if text[index] == "\n" { - if lines == count { return index } - lines += 1 - } - index = text.index(after: index) - } - return index - } - - private static func boundaryBeforeLastLines(_ count: Int, in text: String) -> String.Index { - var remaining = count - var index = text.endIndex - while index > text.startIndex, remaining > 0 { - index = text.index(before: index) - if text[index] == "\n" { remaining -= 1 } - } - if index < text.endIndex, text[index] == "\n" { - return text.index(after: index) - } - return index - } - private static func output( _ raw: String, policy: AgentToolDescriptor.Presentation.Output ) -> String { diff --git a/Frameworks/TermindPresentation/AssistantCompactToolDetails.swift b/Frameworks/TermindPresentation/AssistantCompactToolDetails.swift index c6352dfd..259b1fd1 100644 --- a/Frameworks/TermindPresentation/AssistantCompactToolDetails.swift +++ b/Frameworks/TermindPresentation/AssistantCompactToolDetails.swift @@ -5,13 +5,17 @@ import SwiftUI public struct AssistantCompactToolDetails: View { private let preview: AgentToolDetailPreview private let isError: Bool + private let truncateMarker: String? @Binding private var expanded: Bool - @ScaledMetric(relativeTo: .caption) private var previewHeight: CGFloat = 180 - @State private var contentHeight: CGFloat = 0 - public init(detail: AgentToolDetail, isError: Bool, expanded: Binding) { - preview = AgentToolDetailPreview(detail) + public init( + detail: AgentToolDetail, isError: Bool, expanded: Binding, + strategy: AgentTruncateStrategy = .head, preservesFirstText: Bool = false, + truncateMarker: String? = nil + ) { + preview = AgentToolDetailPreview(detail, strategy: strategy, preservesFirstText: preservesFirstText) self.isError = isError + self.truncateMarker = truncateMarker _expanded = expanded } @@ -22,21 +26,17 @@ public struct AssistantCompactToolDetails: View { ) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) - .onGeometryChange(for: CGFloat.self) { - $0.size.height - } action: { - contentHeight = $0 + if let truncateMarker { + Text(truncateMarker) + .font(.caption2).foregroundStyle(.orange) } - .frame(maxHeight: expanded ? nil : previewHeight, alignment: .top) - .fixedSize(horizontal: false, vertical: true) - .clipped() - if expanded || preview.isShortened || contentHeight > previewHeight { + if expanded || preview.isShortened { Button( expanded ? AppStrings.Assistant.showLess.swiftUIKey : AppStrings.Assistant.showMore.swiftUIKey ) { expanded.toggle() } .accessibilityIdentifier("AssistantTool.More") .font(.caption) - .frame(minHeight: 44) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) } } .accessibilityIdentifier("AssistantTool.CompactDetails") diff --git a/Frameworks/TermindPresentation/SSHExecToolPresentation.swift b/Frameworks/TermindPresentation/SSHExecToolPresentation.swift index 63ec7093..3e742064 100644 --- a/Frameworks/TermindPresentation/SSHExecToolPresentation.swift +++ b/Frameworks/TermindPresentation/SSHExecToolPresentation.swift @@ -70,9 +70,9 @@ nonisolated enum SSHExecToolPresentation { if object["remote_termination_confirmed"]?.boolValue == false { items.append(.message(text(S.cancellationNotice), .warning)) } - let stdout = object["stdout"]?.stringValue ?? "" + let stdout = object["output"]?.stringValue ?? object["stdout"]?.stringValue ?? "" let stderr = object["stderr"]?.stringValue ?? "" - if !stdout.isEmpty { items.append(block(S.stdout, stdout)) } + if !stdout.isEmpty { items.append(block(object["output"] == nil ? S.stdout : S.commandOutput, stdout)) } if !stderr.isEmpty { items.append(block(S.stderr, stderr)) } if ["ssh_exec", "ssh_wait", "ssh_exec_run", "ssh_exec_output"].contains(card.descriptor.name), stdout.isEmpty, stderr.isEmpty diff --git a/Frameworks/TermindPresentation/Strings/Assistant.swift b/Frameworks/TermindPresentation/Strings/Assistant.swift index a2c39d19..a568f6fb 100644 --- a/Frameworks/TermindPresentation/Strings/Assistant.swift +++ b/Frameworks/TermindPresentation/Strings/Assistant.swift @@ -317,10 +317,6 @@ extension AppStrings { "Output shortened for display" } - public static var outputTruncated: LocalizedStringResource { - "Output truncated" - } - public static var pastedImage: LocalizedStringResource { "Pasted image" } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBackgroundCommands.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBackgroundCommands.swift index 27fc0155..e83bef86 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBackgroundCommands.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBackgroundCommands.swift @@ -67,6 +67,7 @@ public actor AgentBackgroundCommands { let task = Task { let result: Result do { result = .success(try await operation(session)) } catch { result = .failure(error) } + _ = await stream.snapshot() self.complete(handle, result: result) switch result { case .success: session.finish() @@ -122,15 +123,26 @@ public actor AgentBackgroundCommands { return (entry.hostID, entry.command) } - public func output(_ handle: UUID, scope: UUID) throws -> AgentJSONValue { + public func output( + _ handle: UUID, scope: UUID, capturePath: (@Sendable () -> String)? = nil + ) async throws -> AgentJSONValue { let entry = try entry(handle, scope: scope) var fields = metadata(handle, entry) fields.removeValue(forKey: "command") fields.removeValue(forKey: "started_at") fields.removeValue(forKey: "finished_at") - let output = entry.stream.captured - fields["stdout"] = .string(output.standardOut) - fields["stderr"] = .string(output.standardError) + let envelopeBytes = + AgentJSONValue.object(fields).encodedString.utf8.count + + AgentToolResult.untrustedDataOpeningMarker.utf8.count + + AgentToolResult.untrustedDataClosingMarker.utf8.count + 16 + let output = await entry.stream.snapshot(envelopeBytes: envelopeBytes, capturePath: capturePath) + fields["output"] = .string(output.output) + fields["output_capture"] = output.capture?.json + fields["output_capture_id"] = .string(entry.stream.captureID.uuidString) + fields["output_revision"] = .number(Double(output.revision)) + fields["output_truncated"] = .bool(output.isTruncated) + fields["output_total_lines"] = .number(Double(output.totalLines)) + fields["output_line_offset"] = .number(Double(output.lineOffset)) return .object(fields) } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSFTPTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSFTPTools.swift index 296299ea..f07eae2b 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSFTPTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSFTPTools.swift @@ -133,7 +133,7 @@ nonisolated public struct SFTPStatTool: SFTPToolDefinition { nonisolated public struct SFTPReadTool: SFTPToolDefinition { public static let name = "sftp_read" public static let summary = - "Read a UTF-8 remote file by lines (offset starts at 1). Pages contain at most 2,000 lines or 50 KiB. Continue at next_offset." + "Read a UTF-8 remote file by lines (offset starts at 1). Pages contain at most 2,000 lines or 64 KiB. Continue at next_offset." public static let extraProperties = [ "offset": integer(min: 1, max: 10_000_000), "limit": integer(min: 1, max: 2_000), ] @@ -148,7 +148,7 @@ nonisolated public struct SFTPReadTool: SFTPToolDefinition { ) public let authentication: AgentSSHAuthenticationRegistry public let connections: AgentConnectionPool - public func estimatedByteCount(arguments _: Arguments) throws -> Int64? { 50 * 1_024 } + public func estimatedByteCount(arguments _: Arguments) throws -> Int64? { Int64(AgentTruncateLimits.maximumBytes) } public func perform( client: any SFTPChannelProtocol, path: String, arguments: Arguments, invocation: AgentToolInvocation diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift index d6190941..4ccfeff8 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift @@ -142,9 +142,12 @@ nonisolated public enum SSHCommandPresentation { F.appendField( &items, "Signal", object["signal"]?.stringValue, locale: input.locale, monospaced: true) - let stdout = object["stdout"]?.stringValue ?? "" + let stdout = object["output"]?.stringValue ?? object["stdout"]?.stringValue ?? "" let stderr = object["stderr"]?.stringValue ?? "" - if !stdout.isEmpty { items.append(F.text("Standard output", stdout, locale: input.locale)) } + if !stdout.isEmpty { + items.append( + F.text(object["output"] == nil ? "Standard output" : "Content", stdout, locale: input.locale)) + } if !stderr.isEmpty { items.append(F.text("Standard error", stderr, locale: input.locale)) } if stdout.isEmpty, stderr.isEmpty, object["note"] == nil { items.append(.message(F.localized("No output", locale: input.locale), .secondary)) diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentCommandOutputStream.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentCommandOutputStream.swift index 6c71de6d..69d67ed2 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentCommandOutputStream.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentCommandOutputStream.swift @@ -1,77 +1,54 @@ +import AgentKit import Foundation import TermindApplication -/// A bounded UTF-8 tail with an in-band count of discarded Unicode scalars. -nonisolated struct AgentCommandOutputBuffer { - private var data = Data() - private var discardedCharacters = 0 - let limit: Int - - private var marker: String { - discardedCharacters == 0 ? "" : "[\(discardedCharacters) chars were truncated]\n" - } - - var text: String { marker + String(decoding: data, as: UTF8.self) } - - mutating func append(_ chunk: Data) { - data.append(chunk) - // The marker also counts toward the byte budget. Its digit count can grow - // after a trim, so tighten again until both the text and marker fit. - while data.count + marker.utf8.count > limit, !data.isEmpty { - var count = min(data.count, max(1, data.count + marker.utf8.count - limit)) - while count < data.count, data[data.startIndex + count] & 0xC0 == 0x80 { count += 1 } - discardedCharacters += String(decoding: data.prefix(count), as: UTF8.self).unicodeScalars.count - data = Data(data.dropFirst(count)) - } - } -} - -/// Locked because the serial SSH pump cannot await an actor. Only progress is -/// throttled; snapshots always contain the latest bounded tail. +/// The SSH pump only enqueues bytes; generic capture, quotas and whole-line +/// selection belong to AgentKit. Both channels share callback-arrival order. nonisolated public final class AgentCommandOutputStream: @unchecked Sendable { private let lock = NSLock() - private var standardOut: AgentCommandOutputBuffer - private var standardError: AgentCommandOutputBuffer + private let accumulator: AgentOutputAccumulator private var lastReportedAt: ContinuousClock.Instant? private let interval: Duration private var report: @Sendable (String, String) -> Void public init( - interval: Duration = .milliseconds(150), limit: Int = 256 * 1_024, + interval: Duration = .milliseconds(150), + accumulator: AgentOutputAccumulator, report: @escaping @Sendable (String, String) -> Void ) { self.interval = interval - standardOut = AgentCommandOutputBuffer(limit: limit) - standardError = AgentCommandOutputBuffer(limit: limit) + self.accumulator = accumulator self.report = report } + public var captureID: UUID { accumulator.id } + public func redirect(to report: @escaping @Sendable (String, String) -> Void) { - let output = lock.withLock { + lock.withLock { self.report = report lastReportedAt = nil - return (standardOut.text, standardError.text) } - report(output.0, output.1) + report(accumulator.preview, "") } public var handler: SSHExecOutputHandler { - { [self] chunk, isStandardError in append(chunk, isStandardError: isStandardError) } + { [self] chunk, _ in append(chunk) } } - public var captured: (standardOut: String, standardError: String) { - lock.withLock { (standardOut.text, standardError.text) } + public func snapshot( + envelopeBytes: Int = 0, capturePath: (@Sendable () -> String)? = nil + ) async -> AgentOutputAccumulator.Snapshot { + await accumulator.snapshot(envelopeBytes: envelopeBytes, capturePath: capturePath) } - private func append(_ chunk: Data, isStandardError: Bool) { - typealias Due = (String, String, @Sendable (String, String) -> Void) - let due: Due? = lock.withLock { - if isStandardError { standardError.append(chunk) } else { standardOut.append(chunk) } + private func append(_ chunk: Data) { + accumulator.append(chunk) + let due: (@Sendable (String, String) -> Void)? = lock.withLock { let now = ContinuousClock.now if let lastReportedAt, now - lastReportedAt < interval { return nil } lastReportedAt = now - return (standardOut.text, standardError.text, report) + return report } - if let due { due.2(due.0, due.1) } + if let due { due(accumulator.preview, "") } } } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentPaneTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentPaneTools.swift index 4abef124..8a1bc84b 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentPaneTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentPaneTools.swift @@ -63,6 +63,7 @@ nonisolated public struct ListPanesTool: AgentToolDefinition, AgentToolSchemaBui nonisolated public struct ReadPaneTool: AgentToolDefinition, AgentToolSchemaBuilding { public static let presenter = AgentToolDetailPresenter(id: "builtin.read_pane", present: present) public let panes: AgentPaneAccess + public var truncateStrategy: AgentTruncateStrategy { .none } public init(panes: AgentPaneAccess) { self.panes = panes } @@ -112,32 +113,18 @@ nonisolated public struct ReadPaneTool: AgentToolDefinition, AgentToolSchemaBuil case .noSurface: throw AgentToolError.invalidArguments("The pane's terminal surface is not established.") case .text(let pane, let text): - let (limited, truncated) = Self.limitUTF8(text) return Self.result( invocation, .object([ "pane_id": .string(pane.id.uuidString), "title": .string(pane.title), "host_id": pane.hostID.map { .string($0.uuidString) } ?? .null, - "text": .string(limited), - "truncated": .bool(truncated), - ]), truncated: truncated) + "text": .string(text), + "truncated": .bool(false), + ])) } } - public static func limitUTF8(_ text: String, maxBytes: Int = 65_536) -> (String, Bool) { - guard text.utf8.count > maxBytes else { return (text, false) } - var bytes = 0 - var end = text.startIndex - while end < text.endIndex { - let next = text.index(after: end) - let size = text[end.. maxBytes { break } - bytes += size - end = next - } - return (String(text[.. AgentToolPreflight { diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/README.md b/Frameworks/TermindRemoteInfrastructure/Agent/README.md new file mode 100644 index 00000000..50ba9d54 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Agent/README.md @@ -0,0 +1,105 @@ +# Tool Output Policies + +AgentKit owns whole-line truncation, optional output accumulation, scratch writes, +and model-output markers. Termind owns its tool policies and generated paths. +The default model ceiling is 64 KiB (65,536 UTF-8 bytes) and 2,000 logical lines, +including JSON escaping and the response envelope in the byte budget. A line that +cannot fit is omitted, never split. `none` explicitly bypasses this ceiling. + +## Model Strategies and Automatic Capture + +Every assistant-visible tool is listed below. Capture is a tool implementation +choice, not a model parameter or a Scratch enable/disable switch. + +| Tools | Strategy | Automatic capture | +| --- | --- | --- | +| `ssh_exec_run` | `tail` | Cumulative command output | +| `ssh_exec_output` | `tail` | Complete snapshot of the command, not the already-shortened preview | +| Dynamic outbound MCP tools | `middle` | Received result; filename uses the called tool name | +| `fetch`, `web_search` | `head` | Received document/search output | +| `read_pane` | `none` | No | +| `sftp_read`, `scratch_read` | `head` | No; continue with the returned offset | +| `skill_read_file` | `head` for supporting files; trusted `SKILL.md` bypasses projection | No; supporting files retain their line pagination | +| `read_chat` | `head` | No; cursor continues at the first unreturned message/line | +| `memory_search`, `session_search`, `list_chats` | `head` | No; retain their query, result-count and pagination contracts | +| `scratch_search` | Default `head` | No; narrow the query/path or use `scratch_read` | +| `get_metrics`, `list_hosts`, `list_panes`, `ssh_exec_list` | `head` | No | +| `sftp_list`, `sftp_stat`, `scratch_list`, `skills_list`, `list_models` | `head` | No | +| `scratch_diff` | `head` | No; source files remain available | +| `ssh_exec_stdin`, `ssh_exec_cancel`, `terminal_suggest` | `head` | No | +| `sftp_write`, `sftp_mkdir`, `sftp_move`, `sftp_copy`, `sftp_chmod`, `sftp_delete` | `head` | No | +| `scratch_pull`, `scratch_push`, `scratch_fetch` | `head` | No automatic capture; these tools already transfer files to an explicit destination | +| `scratch_write`, `scratch_replace`, `scratch_copy`, `scratch_move`, `scratch_delete` | `head` | No | +| `request_user_input`, `present_plan`, `manage_tasks` | `head` | No | +| `skill_manage`, `skill_install`, `memory` | `head` | No | +| `create_new_chat`, `send_to_chat`, `wait_chats` | `head` | No | +| `request_user_secret` | Secret-result bypass | Never capture secrets | + +Planning wrappers preserve their wrapped tool's strategy. Provider-native results +and already-frozen model projections retain their separate contracts. + +External clients calling the Termind MCP server bypass model projection and +automatic capture. This also covers the external-only `sftp_upload`, +`sftp_upload_status`, `sftp_download`, and `sftp_download_status` tools. +Intrinsic source limits, such as file-read pagination, are not removed. + +## Paths and Shared Scratch + +Scratch is shared by all conversations on this device. Its root is +Application Support / Termind / Scratch (inside the app container on sandboxed +macOS). A conversation deletion or fork does not delete or copy these files. + +The path factory returns a complete scratch-relative path. AgentKit does not +prepend `output/`, `outputs/`, a conversation ID, or an extension. +Termind's tool convention is: + +| Producer | Path | +| --- | --- | +| `ssh_exec_run` | `/outputs/ssh-exec-.txt` | +| `ssh_exec_output` | `/outputs/ssh-exec-output-.txt` | +| `fetch` | `/outputs/fetch-.txt` | +| `web_search` | `/outputs/web-search-.txt` | +| Outbound MCP | `/outputs/-.txt` | + +Tool-name underscores become hyphens. Contexts without a conversation ID use +their run ID. This is file organization, not conversation isolation; the agent +must still provide explicit relative paths when reading or writing its own files. +Parent directories are created automatically. Generated collisions are not +overwritten. Existing containment, symlink and quota checks still apply. + +A command's collector reuses one cumulative file. Each truncated +`ssh_exec_output` call captures its own snapshot under its own tool name. Files +contain redacted output through that snapshot, not future command output. +Capture is lazy, limited to 8 MiB per file and the shared 64 MiB/512-entry quota. +Quota or I/O failure must not claim that a complete saved copy exists. + +## Markers and Presentation + +AgentKit supplies two unlocalized, bracketed marker forms, following Pi: + +```text +[Showing lines 101-2100 of 2100. Full output: ] +[Showing lines 1-2000 of 2100] +``` + +`middle` lists both retained ranges. Empty selections report zero returned lines; +paginated sources with unknown total length omit the total. The `Full output` +clause appears only after a successful capture. Failure details and continuation +offsets/cursors live in separate result fields, not extra markers. + +Presentation never generates these markers. It may display the marker frozen by +AgentKit for actual tool/model truncation. Display-only folding adds none: + +- Compact bodies share five complete logical lines/list items and 64 KiB. SSH + keeps the tail; command metadata remains outside the body budget. +- Expansion uses the card's full retained content, without height clipping. +- Existing truncation markers appear above the left-aligned Show more/Show less + controls. +- Other text previews use `middle`, 120 lines and 64 KiB, retaining full content + for Copy. +- Display folding never rewrites model content or creates Scratch files. + +Tool-specific source limits remain where they have meaning: file pagination, +search match counts, metric process counts, skill validation quotas and +hunk-aligned diff computation/output limits. They are not additional arbitrary +character cuts. `read_chat` no longer uses its former 8,000/40,000-character cuts. diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecControlTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecControlTools.swift index 96fe0cb6..2fbce837 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecControlTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecControlTools.swift @@ -5,10 +5,20 @@ import TermindDomain nonisolated enum SSHExecResults: AgentToolSchemaBuilding { static func snapshot(_ invocation: AgentToolInvocation, _ value: AgentJSONValue) -> AgentToolResult { - var result = result(invocation, value) - result.isError = value.objectValue?["status"]?.stringValue == "failed" - // Generic JSON projection would discard the handle on large command output. - result.hasBoundedModelContent = true + var fields = value.objectValue ?? [:] + let capture = fields.removeValue(forKey: "output_capture").flatMap(AgentOutputCaptureInfo.init(json:)) + let id = fields.removeValue(forKey: "output_capture_id") + let revision = fields.removeValue(forKey: "output_revision") + let totalLines = fields.removeValue(forKey: "output_total_lines") + let lineOffset = fields.removeValue(forKey: "output_line_offset") + let truncated = fields.removeValue(forKey: "output_truncated")?.boolValue ?? false + var result = result(invocation, .object(fields), truncated: truncated) + result.isError = fields["status"]?.stringValue == "failed" + result.outputCapture = capture + result.metadata["output_capture_id"] = id + result.metadata["output_revision"] = revision + result.metadata["output_total_lines"] = totalLines + result.metadata["output_line_offset"] = lineOffset return result } @@ -31,6 +41,7 @@ nonisolated public struct SSHExecOutputTool: AgentToolDefinition, AgentToolSchem presentArguments: SSHExecResults.handleArguments) public let background: AgentBackgroundCommands public let scope: UUID + public var truncateStrategy: AgentTruncateStrategy { .tail } public init(background: AgentBackgroundCommands, scope: UUID) { self.background = background @@ -40,7 +51,7 @@ nonisolated public struct SSHExecOutputTool: AgentToolDefinition, AgentToolSchem public var descriptor: AgentToolDescriptor { Self.descriptor( "ssh_exec_output", - "Return the current cumulative ssh_exec_run snapshot immediately. Reads do not consume output or extend retention. No waiting or timeout.", + "Return the current cumulative ssh_exec_run snapshot immediately. Reads do not consume output or extend retention. No waiting or timeout. Combined output uses complete-line tail selection at 64 KiB / 2000 lines; truncation notices include the generated scratch path for scratch_read or scratch_search.", properties: ["handle": Self.string(max: 64)], required: ["handle"], target: .host, approvalPolicy: .approve, concurrency: .parallel, presentation: .init( @@ -52,7 +63,10 @@ nonisolated public struct SSHExecOutputTool: AgentToolDefinition, AgentToolSchem -> AgentToolResult { SSHExecResults.snapshot( - invocation, try await background.output(SSHExecResults.handle(invocation), scope: scope)) + invocation, + try await background.output(SSHExecResults.handle(invocation), scope: scope) { + "\(scope.uuidString.lowercased())/outputs/ssh-exec-output-\(UUID().uuidString.lowercased()).txt" + }) } } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecRunTool.swift b/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecRunTool.swift index 9c40d042..66146a2e 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecRunTool.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecRunTool.swift @@ -11,6 +11,7 @@ nonisolated public struct SSHExecRunTool: AgentToolDefinition, AgentToolSchemaBu public let pool: AgentConnectionPool public let background: AgentBackgroundCommands public let scope: UUID + public var truncateStrategy: AgentTruncateStrategy { .tail } public init( authentication: AgentSSHAuthenticationRegistry, pool: AgentConnectionPool, @@ -29,8 +30,10 @@ nonisolated public struct SSHExecRunTool: AgentToolDefinition, AgentToolSchemaBu Run a non-PTY SSH command. Discover host_id with list_hosts when needed; Termind handles login. \ run_in_background defaults to false: wait for completion without a runtime timeout and close stdin. \ Set true for long-lived commands or commands needing ssh_exec_stdin; return after confirmed startup. \ - Returns handle, host_id, status (running/exited/failed), stdout, stderr, exit_code, signal, \ - stdin_closed and error. Output keeps the latest 256 KiB per stream with an in-band truncation count. \ + Returns handle, host_id, status (running/exited/failed), combined output, exit_code, signal, \ + stdin_closed and error. Model output keeps the latest complete lines within 64 KiB / 2000 lines. \ + Truncated output is saved to /outputs/ssh-exec-.txt in shared scratch; \ + use scratch_read or scratch_search with that exact path. Capture failures are explicit. \ Handles belong to this conversation/session; finished results remain for ten minutes. \ Use ssh_exec_output for snapshots and ssh_exec_cancel for local cleanup. Never include plaintext secrets. """, @@ -91,11 +94,14 @@ nonisolated public struct SSHExecRunTool: AgentToolDefinition, AgentToolSchemaBu let hostID = try arguments.uuid("host_id") let command = try arguments.string("command") let isBackground = arguments.optionalBool("run_in_background") ?? false - let stream = AgentCommandOutputStream { out, error in + let accumulator = context.makeOutputAccumulator { + "\(scope.uuidString.lowercased())/outputs/ssh-exec-\(UUID().uuidString.lowercased()).txt" + } + let stream = AgentCommandOutputStream(accumulator: accumulator) { out, _ in var result = Self.result( invocation, .object([ - "status": .string("running"), "stdout": .string(out), "stderr": .string(error), + "status": .string("running"), "output": .string(out), ])) result.metadata["presentation_phase"] = .string("command") context.report(result) diff --git a/TFI/Features/AI/MobileAssistantWorkRow.swift b/TFI/Features/AI/MobileAssistantWorkRow.swift index bee22c32..6bff408e 100644 --- a/TFI/Features/AI/MobileAssistantWorkRow.swift +++ b/TFI/Features/AI/MobileAssistantWorkRow.swift @@ -135,7 +135,10 @@ struct MobileAssistantToolRow: View { presentation.toggleDisclosure(.toolDetail(card.id)) } } - ) + ), + strategy: AgentToolPresentation.compactStrategy(for: card.descriptor), + preservesFirstText: card.invocation.call.arguments.objectValue?["command"] != nil, + truncateMarker: AgentToolPresentation.truncationNotice(for: card) ) } else { AssistantToolDetails(items: detail.items, isError: card.result?.isError == true) { @@ -143,8 +146,11 @@ struct MobileAssistantToolRow: View { } } } - if AgentToolPresentation.showsOutputTruncatedNotice(for: card) { - Text(AppStrings.Assistant.outputTruncated).font(.caption2).foregroundStyle(.orange) + if !AgentToolPresentation.usesCompactDetails(for: card.descriptor), + let marker = AgentToolPresentation.truncationNotice(for: card) + { + Text(marker) + .font(.caption2).foregroundStyle(.orange) } } .padding(.leading, iconSize + 8) diff --git a/TFM/Application/MCPServer/MCPServerTools.swift b/TFM/Application/MCPServer/MCPServerTools.swift index 67de50bb..f34b22e9 100644 --- a/TFM/Application/MCPServer/MCPServerTools.swift +++ b/TFM/Application/MCPServer/MCPServerTools.swift @@ -146,7 +146,8 @@ final class MCPServerTools { services: AgentToolServices() .setting(handler.sshInteraction, for: .sshInteraction), runID: runID, permissionMode: .fullAccess, - userIntent: "MCP client: \(clientName)" + userIntent: "MCP client: \(clientName)", + outputProjection: AgentToolOutputProjection(audience: .externalClient) ) let call = AgentToolCall(id: callID, name: name, arguments: arguments) let id = UUID() diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index cd199b1c..f46c10a8 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -846,10 +846,12 @@ private enum NativeTranscriptFactory { } } } - if AgentToolPresentation.showsOutputTruncatedNotice(for: card) { + if !AgentToolPresentation.usesCompactDetails(for: card.descriptor), + let marker = AgentToolPresentation.truncationNotice(for: card) + { detail.addArrangedSubview( NativeLabel( - String(localized: AppStrings.Assistant.outputTruncated), + marker, font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1), color: .systemOrange )) @@ -1360,7 +1362,9 @@ private enum NativeTranscriptFactory { private static func compactToolDetail( _ detail: AgentToolDetail, card: AgentToolCard, context: NativeTranscriptContext ) -> NSView { - let preview = AgentToolDetailPreview(detail) + let preview = AgentToolDetailPreview( + detail, strategy: AgentToolPresentation.compactStrategy(for: card.descriptor), + preservesFirstText: card.invocation.call.arguments.objectValue?["command"] != nil) let id = AssistantTranscriptDisclosureID.toolDetail(card.id) let expanded = context.presentation.disclosures.isExpanded(id) let body = NativeStack.vertical() @@ -1368,26 +1372,25 @@ private enum NativeTranscriptFactory { body.addArrangedSubview( toolDetail(item, isError: card.result?.isError == true, context: context, shortensText: false)) } - let scale = max(1, NSFont.preferredFont(forTextStyle: .caption1).pointSize / NSFont.smallSystemFontSize) - let height = 180 * scale - let width = body.widthAnchor.constraint(equalToConstant: max(1, context.contentWidth - 48)) - width.isActive = true - body.layoutSubtreeIfNeeded() - let needsMore = preview.isShortened || body.fittingSize.height > height - width.isActive = false - let clipped = NativePlanPreview(content: body, expanded: expanded, previewHeight: height) + let needsMore = preview.isShortened let toggle = { context.presentation.disclosures.toggleDisclosure(id) context.invalidate(context.entryID, false) } - clipped.handler = needsMore ? toggle : nil let stack = NativeStack.vertical() - stack.addArrangedSubview(clipped) + stack.addArrangedSubview(body) + if let marker = AgentToolPresentation.truncationNotice(for: card) { + let notice = NativeLabel( + marker, font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1), color: .systemOrange) + notice.identifier = NSUserInterfaceItemIdentifier("AssistantTool.TruncationNotice") + stack.addArrangedSubview(notice) + } if expanded || needsMore { let more = NativeButton( title: AppLocalization.resolve( expanded ? AppStrings.Assistant.showLess : AppStrings.Assistant.showMore, locale: context.configuration.locale)) + more.alignment = .left more.identifier = NSUserInterfaceItemIdentifier("AssistantTool.More") more.handler = toggle stack.addArrangedSubview(more) diff --git a/TFMTests/AgentBackgroundCommandTests.swift b/TFMTests/AgentBackgroundCommandTests.swift index e4185614..7a144b8a 100644 --- a/TFMTests/AgentBackgroundCommandTests.swift +++ b/TFMTests/AgentBackgroundCommandTests.swift @@ -12,12 +12,61 @@ private nonisolated func exited(_ code: Int32 = 0) -> SSHExecResult { @Suite struct AgentBackgroundCommandTests { + @Test + func outputToolCapturesEachFullSnapshotWithItsOwnNameUnderTheConversation() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let workspace = AgentScratchWorkspace(base: directory) + let scope = UUID() + let accumulator = AgentOutputAccumulator( + workspace: workspace, + path: { + "\(scope.uuidString.lowercased())/outputs/ssh-exec-\(UUID().uuidString.lowercased()).txt" + }) + let stream = AgentCommandOutputStream(accumulator: accumulator) { _, _ in } + let commands = AgentBackgroundCommands(sweepInterval: nil) + let source = "first\n" + String(repeating: "output\n", count: 2_100) + "last>" + let handle = try await commands.start( + scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: false, stream: stream + ) { session in + session.start { _ in 0 } + stream.handler(Data(source.utf8), false) + return exited() + } + try await commands.waitForCompletion(handle, scope: scope) + let run = await stream.snapshot() + #expect(run.capture?.path?.hasPrefix("\(scope.uuidString.lowercased())/outputs/ssh-exec-") == true) + let tool = SSHExecOutputTool(background: commands, scope: scope) + let call = AgentToolInvocation( + runID: UUID(), + call: .init( + id: "output", name: "ssh_exec_output", + arguments: .object([ + "handle": .string(handle.uuidString) + ]))) + let context = AgentToolExecutionContext( + runID: call.runID, conversationID: scope, userIntent: "read output", secretBroker: SecretBroker(), + userInteraction: UnavailableAgentUserInteraction(), preflightMetadata: [:], + authorize: { _ in .allow }, workspace: workspace) + let first = try await tool.execute(call, context: context) + let second = try await tool.execute(call, context: context) + let path = try #require(first.outputCapture?.path) + #expect(path.hasPrefix("\(scope.uuidString.lowercased())/outputs/ssh-exec-output-")) + #expect(path.hasSuffix(".txt")) + #expect(second.outputCapture?.path != path) + #expect(try await workspace.data(at: path).data == Data(source.utf8)) + #expect(first.metadata["output_total_lines"] == .number(2_102)) + await commands.closeAll() + } + @Test func outputIsImmediateCumulativeAndRepeatableAfterCompletion() async throws { let commands = AgentBackgroundCommands(sweepInterval: nil) let scope = UUID() let release = AsyncRelease() - let stream = AgentCommandOutputStream { _, _ in } + let stream = AgentCommandOutputStream( + accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" }) + ) { _, _ in } let handle = try await commands.start( scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: false, stream: stream ) { session in @@ -28,7 +77,7 @@ struct AgentBackgroundCommandTests { stream.handler(Data("step one\n".utf8), false) let running = try await commands.output(handle, scope: scope) #expect(running.objectValue?["status"] == .string("running")) - #expect(running.objectValue?["stdout"] == .string("step one\n")) + #expect(running.objectValue?["output"] == .string("step one\n")) #expect(running.objectValue?["stdin_closed"] == .bool(true)) #expect(try await commands.output(handle, scope: scope) == running) @@ -39,8 +88,7 @@ struct AgentBackgroundCommandTests { let finished = try await commands.output(handle, scope: scope) #expect(finished.objectValue?["status"] == .string("exited")) #expect(finished.objectValue?["exit_code"] == .number(7)) - #expect(finished.objectValue?["stdout"] == .string("step one\nstep two\n")) - #expect(finished.objectValue?["stderr"] == .string("failure\n")) + #expect(finished.objectValue?["output"] == .string("step one\nstep two\nfailure\n")) #expect(try await commands.output(handle, scope: scope) == finished) #expect(await commands.count == 1) await commands.closeAll() @@ -52,7 +100,9 @@ struct AgentBackgroundCommandTests { await #expect(throws: CocoaError.self) { try await commands.start( scope: UUID(), hostID: UUID(), command: "build", keepsStdinOpen: true, - stream: AgentCommandOutputStream { _, _ in } + stream: AgentCommandOutputStream( + accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" }) + ) { _, _ in } ) { _ in throw CocoaError(.fileReadNoPermission) } } #expect(await commands.count == 0) @@ -68,7 +118,9 @@ struct AgentBackgroundCommandTests { let task = Task { try await commands.start( scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: true, - stream: AgentCommandOutputStream { _, _ in } + stream: AgentCommandOutputStream( + accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" }) + ) { _, _ in } ) { session in await connecting.release() await release.wait() @@ -94,7 +146,9 @@ struct AgentBackgroundCommandTests { let release = AsyncRelease() let handle = try await commands.start( scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: true, - stream: AgentCommandOutputStream { _, _ in } + stream: AgentCommandOutputStream( + accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" }) + ) { _, _ in } ) { session in session.start { _ in 0 } await release.wait() @@ -122,7 +176,9 @@ struct AgentBackgroundCommandTests { let release = AsyncRelease() let handle = try await commands.start( scope: scope, hostID: host, command: "build", keepsStdinOpen: true, - stream: AgentCommandOutputStream { _, _ in } + stream: AgentCommandOutputStream( + accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" }) + ) { _, _ in } ) { session in session.start { _ in 0 } await release.wait() @@ -132,7 +188,7 @@ struct AgentBackgroundCommandTests { #expect(await commands.list(scope: scope, hostID: UUID()).objectValue?["commands"] == .array([])) let rows = await commands.list(scope: scope, hostID: host).objectValue?["commands"]?.arrayValue #expect(rows?.count == 1) - #expect(rows?.first?.objectValue?["stdout"] == nil) + #expect(rows?.first?.objectValue?["output"] == nil) await #expect(throws: AgentBackgroundCommandError.self) { try await commands.cancel(handle, scope: otherScope) } @@ -155,7 +211,9 @@ struct AgentBackgroundCommandTests { let writes = InputLog() let handle = try await commands.start( scope: scope, hostID: UUID(), command: "cat", keepsStdinOpen: true, - stream: AgentCommandOutputStream { _, _ in } + stream: AgentCommandOutputStream( + accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" }) + ) { _, _ in } ) { session in session.start { try await writes.write($0) } await release.wait() @@ -184,7 +242,9 @@ struct AgentBackgroundCommandTests { let signal = AgentInterjectionSignal() let handle = try await commands.start( scope: scope, hostID: UUID(), command: "build", keepsStdinOpen: false, - stream: AgentCommandOutputStream { _, _ in } + stream: AgentCommandOutputStream( + accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" }) + ) { _, _ in } ) { session in session.start { _ in 0 } await release.wait() @@ -224,7 +284,9 @@ struct AgentBackgroundCommandTests { let writes = InputLog() let handle = try await commands.start( scope: scope, hostID: hostID, command: "cat", keepsStdinOpen: true, - stream: AgentCommandOutputStream { _, _ in } + stream: AgentCommandOutputStream( + accumulator: AgentOutputAccumulator(path: { "tests/outputs/ssh-exec-\(UUID().uuidString).txt" }) + ) { _, _ in } ) { session in session.start { try await writes.write($0) } await release.wait() diff --git a/TFMTests/AgentBuiltInToolAvailabilityTests.swift b/TFMTests/AgentBuiltInToolAvailabilityTests.swift index 8dcf04f4..0fb0f280 100644 --- a/TFMTests/AgentBuiltInToolAvailabilityTests.swift +++ b/TFMTests/AgentBuiltInToolAvailabilityTests.swift @@ -311,15 +311,16 @@ struct AgentBuiltInToolAvailabilityTests { #expect(all.isSuperset(of: ["fetch", "web_search", "skill_read_file", "present_plan"])) } - /// A selected group whose dependency is missing contributes nothing. The - /// alternative — registering `fetch` with no fetcher — puts a tool in front - /// of the model that can only ever fail. + /// Missing optional services never hide the mandatory shared scratch workspace. @Test - func aSelectedGroupWithoutItsDependencyRegistersNothing() { + func groupsWithoutTheirServicesStillRegisterMandatoryScratch() { let names = Set( AgentToolCatalog.registry(builtIn: .init(groups: [.web, .scratch, .planning])) .descriptors.map(\.name) ) - #expect(names.isEmpty) + #expect(names.contains("scratch_read")) + #expect(names.contains("scratch_write")) + #expect(names.allSatisfy { $0.hasPrefix("scratch_") }) + #expect(!names.contains("scratch_fetch")) } } diff --git a/TFMTests/AgentKnowledgeToolPresentationTests.swift b/TFMTests/AgentKnowledgeToolPresentationTests.swift index c235f0a6..0681f61c 100644 --- a/TFMTests/AgentKnowledgeToolPresentationTests.swift +++ b/TFMTests/AgentKnowledgeToolPresentationTests.swift @@ -131,8 +131,10 @@ import Testing #expect(preview.isShortened) #expect(preview.fullItems == detail.items) let rendered = AgentToolDetailPreview(AgentToolDetail(items: preview.items)).fullText - // Separators are formatting, not source text; all source strings share the 600-character budget. - #expect(rendered.count <= 610) + // Five complete logical lines share the card body budget; headings are metadata. + #expect(rendered.utf8.count <= AgentToolDetailPreview.byteLimit + 100) + #expect(preview.items.count == 5) + #expect(rendered.hasSuffix(text)) #expect(preview.fullText.contains("Block 31")) #expect(preview.fullText.hasSuffix(text)) } diff --git a/TFMTests/AgentPaneToolsTests.swift b/TFMTests/AgentPaneToolsTests.swift index 6732ff9d..2f799981 100644 --- a/TFMTests/AgentPaneToolsTests.swift +++ b/TFMTests/AgentPaneToolsTests.swift @@ -171,14 +171,20 @@ struct AgentPaneToolsTests { } @Test - func readTruncatesOnUnicodeBoundaryWithoutNormalizingLines() async throws { + func readPreservesTheCompleteViewportWithoutTruncation() async throws { + let fixture = Fixture() let input = "α \n" + String(repeating: "😀", count: 20_000) - let (limited, truncated) = ReadPaneTool.limitUTF8(input) - #expect(truncated) - #expect(limited.utf8.count <= 65_536) - #expect(limited.hasPrefix("α \n")) - #expect(String(decoding: Array(limited.utf8), as: UTF8.self) == limited) - #expect(!ReadPaneTool.limitUTF8(" \n\n").1) + fixture.texts[fixture.first] = input + let tool = ReadPaneTool(panes: fixture.access()) + let call = invocation("read_pane", paneID: fixture.first.uuidString) + let result = try await tool.execute(call, context: context(call)) + let projected = await AgentToolOutputProjection().project( + result, invocation: call, strategy: tool.truncateStrategy) + #expect(tool.truncateStrategy == .none) + #expect(projected.modelContent == result.content) + let payload = try #require(result.untrustedPayload) + #expect(try AgentJSONValue.decode(Data(payload.utf8)).objectValue?["text"] == .string(input)) + #expect(!result.isTruncated) } @Test @@ -279,26 +285,28 @@ struct AgentPaneToolsTests { } @Test - func largeReadMarksSourceTruncationInResultAndModelProjection() async throws { + func largePaneReadsBypassProjectionAndDoNotImpersonateMCPMarkers() async throws { let fixture = Fixture() fixture.texts[fixture.first] = String(repeating: "😀", count: 20_000) let call = invocation("read_pane", paneID: fixture.first.uuidString) let tool = ReadPaneTool(panes: fixture.access()) let result = try await tool.execute(call, context: context(call)) - #expect(result.isTruncated) - #expect(try payload(result)["truncated"] == .bool(true)) - #expect(try #require(payload(result)["text"]?.stringValue).utf8.count == 65_536) - let projected = await AgentToolOutputProjection(maximumBytes: 128).project(result, invocation: call) + #expect(!result.isTruncated) + #expect(try payload(result)["truncated"] == .bool(false)) + #expect(try #require(payload(result)["text"]?.stringValue).utf8.count == 80_000) + let projected = await AgentToolOutputProjection(maximumBytes: 128).project( + result, invocation: call, strategy: tool.truncateStrategy) let model = try #require(projected.modelContent) - #expect(model.contains("\"source_truncated\":true")) - #expect(model.contains("source_notice")) + #expect(model == result.content) + #expect(projected.truncateMarker == nil) let card = AgentToolCard(invocation: call, descriptor: tool.descriptor, state: .completed, result: result) #expect(!AgentToolPresentation.showsOutputTruncatedNotice(for: card)) let mcp = AgentToolDescriptor( name: "read_pane", summary: "A separate MCP tool", inputSchema: .object([:]), target: .mcp, approvalPolicy: .approve, concurrency: .parallel) - let mcpCard = AgentToolCard(invocation: call, descriptor: mcp, state: .completed, result: result) + var mcpCard = AgentToolCard(invocation: call, descriptor: mcp, state: .completed, result: result) + mcpCard.result?.truncateMarker = AgentTruncateMarker.render(ranges: [1...10], totalLines: 20) #expect(AgentToolPresentation.showsOutputTruncatedNotice(for: mcpCard)) } diff --git a/TFMTests/AgentScratchTests.swift b/TFMTests/AgentScratchTests.swift index e350e43c..8e8adc8d 100644 --- a/TFMTests/AgentScratchTests.swift +++ b/TFMTests/AgentScratchTests.swift @@ -45,7 +45,7 @@ struct AgentScratchTests { let outside = base.appending(path: "outside.txt") try Data("outside".utf8).write(to: outside) - let scope = base.appending(path: id.uuidString) + let scope = base try FileManager.default.createSymbolicLink( at: scope.appending(path: "link.txt"), withDestinationURL: outside ) @@ -58,23 +58,22 @@ struct AgentScratchTests { await #expect(throws: (any Error).self) { try await workspace.read("inside.txt") } // And it is not reported as content either, so nothing can act on it. let listed = try await workspace.list(nil, recursive: true).map(\.path) - #expect(listed == ["anchor.txt"]) + #expect(listed == ["anchor.txt", "outside.txt"]) let searched = try await workspace.search("anchor") #expect(searched.matches.map(\.path) == ["anchor.txt"]) - #expect(searched.searchedFiles == 1) + #expect(searched.searchedFiles == 2) } @Test - func twoConversationsCannotSeeEachOther() async throws { + func conversationsShareFilesAndDeletionDoesNotRemoveScratch() async throws { let base = Fixture.base() let first = AgentScratchWorkspace(conversationID: UUID(), base: base) let second = AgentScratchWorkspace(conversationID: UUID(), base: base) _ = try await first.write("notes.md", data: Data("first".utf8)) - #expect(try await second.list(nil, recursive: true).isEmpty) - await #expect(throws: (any Error).self) { try await second.read("notes.md") } + #expect(try await second.read("notes.md").content == "first") - // And deleting one scope leaves the other alone. + // A conversation lifecycle event never owns the shared files. _ = try await second.write("notes.md", data: Data("second".utf8)) try await first.removeScope() #expect(try await second.read("notes.md").content == "second") @@ -235,7 +234,8 @@ struct AgentScratchTests { #expect(outcome.matchingLines == 3) #expect(outcome.matches.count == 1) #expect(outcome.matches[0].text.contains("target")) - #expect(outcome.matches[0].textTruncated) + #expect(!outcome.matches[0].textTruncated) + #expect(outcome.matches[0].text == longLine) #expect(outcome.isTruncated) } @@ -623,7 +623,7 @@ struct AgentScratchTests { #expect(properties["scratch_path"] == nil) #expect(properties.keys.sorted() == ["format", "url"]) #expect(fetch?.approvalPolicy == .ask) - #expect(!tools.contains { $0.descriptor.name.hasPrefix("scratch_") }) + #expect(tools.contains { $0.descriptor.name == "scratch_read" }) } /// The built-in SFTP tools were deliberately left alone: `scratch_pull` and @@ -635,7 +635,7 @@ struct AgentScratchTests { connections: Fixture.pool(StubTransferSFTPClient()) ) let names = tools.map(\.descriptor.name) - #expect(!names.contains { $0.hasPrefix("scratch_") }) + #expect(names.contains("scratch_read")) for name in ["sftp_read", "sftp_write"] { let properties = tools.first { $0.descriptor.name == name }? @@ -651,7 +651,7 @@ struct AgentScratchTests { } @Test - func searchToolBoundsItsEncodedTranscriptResult() async throws { + func searchUsesDefaultProjectionRatherThanItsOwnResponseCeiling() async throws { let workspace = Fixture.workspace() let line = String(repeating: "\u{0001}", count: 1_100) + "target" let body = Array(repeating: line, count: 100).joined(separator: "\n") @@ -671,11 +671,16 @@ struct AgentScratchTests { let object = try #require( try AgentJSONValue.decode(Data(payload.utf8)).objectValue ) - #expect(payload.utf8.count <= 128 * 1_024) - #expect(result.isTruncated) + #expect(payload.utf8.count > 65_536) + let projected = await AgentToolOutputProjection(workspace: workspace).project( + result, invocation: Fixture.invocation("scratch_search", [:]), strategy: tool.truncateStrategy) + #expect(try #require(projected.modelContent).utf8.count <= 65_536) + #expect(projected.metadata["output_truncated"] == .bool(true)) + #expect(projected.outputCapture == nil) + #expect(tool.truncateStrategy == .head) #expect(object["matching_lines"]?.integerValue == 100) - #expect((object["returned_matches"]?.integerValue ?? 100) < 100) - #expect(object["truncated"]?.boolValue == true) + #expect(object["returned_matches"]?.integerValue == 100) + #expect(object["truncated"]?.boolValue == false) } // MARK: - Transfer @@ -875,7 +880,7 @@ struct AgentScratchTests { #expect(try await workspace.data(at: "docs/page.md").data == Data(page.utf8)) } - /// The reason the tool exists. `fetch` truncates at 256 KB because that is what + /// Scratch fetch writes the whole document without a model preview; /// crosses the context window; nothing does here, so the page arrives whole. @Test func fetchIsNotBoundedByTheInlineCeilingThatBoundsTheResultOfFetch() async throws { diff --git a/TFMTests/AgentToolPresentationTests.swift b/TFMTests/AgentToolPresentationTests.swift index dd73e918..24a946b8 100644 --- a/TFMTests/AgentToolPresentationTests.swift +++ b/TFMTests/AgentToolPresentationTests.swift @@ -395,7 +395,7 @@ struct AgentToolPresentationTests { return } #expect(list.rows.count == AgentToolDetailPreview.rowLimit) - #expect(displayed.count <= AgentToolDetailPreview.characterLimit + 20) + #expect(displayed.count <= AgentToolDetailPreview.byteLimit + 20) } @Test @@ -844,24 +844,24 @@ struct AgentToolPresentationTests { #expect(preview.isShortened) #expect(preview.full == full) - #expect(preview.displayed.count <= AgentToolPresentation.displayCharacterLimit) + #expect(preview.displayed.utf8.count <= AgentToolPresentation.displayByteLimit) #expect( preview.displayed.reduce(1) { $1 == "\n" ? $0 + 1 : $0 } <= AgentToolPresentation.displayLineLimit ) #expect(preview.displayed.hasPrefix("line 0")) - #expect(preview.displayed.contains("characters omitted")) + #expect(preview.displayed.contains("\n...\n")) #expect(preview.displayed.hasSuffix(String(repeating: "x", count: 80))) #expect(preview.omittedCharacters > 0) } @Test - func outputPreviewCountsUnicodeCharactersWithoutCorruptingEitherEnd() { - let full = "开头🧭" + String(repeating: "中", count: 200) + "结尾✅" - let preview = AgentToolPresentation.preview(full, characterLimit: 80, lineLimit: 120) + func outputPreviewKeepsCompleteUnicodeLinesAtEitherEnd() { + let full = "开头🧭\n" + String(repeating: "中\n", count: 200) + "结尾✅" + let preview = AgentToolPresentation.preview(full, byteLimit: 80, lineLimit: 120) #expect(preview.isShortened) - #expect(preview.displayed.count <= 80) + #expect(preview.displayed.utf8.count <= 80) #expect(preview.displayed.hasPrefix("开头🧭")) #expect(preview.displayed.hasSuffix("结尾✅")) #expect(preview.full == full) diff --git a/TFMTests/AgentToolProgressTests.swift b/TFMTests/AgentToolProgressTests.swift index d27f260a..2c15dfd0 100644 --- a/TFMTests/AgentToolProgressTests.swift +++ b/TFMTests/AgentToolProgressTests.swift @@ -125,7 +125,9 @@ struct AgentCommandOutputStreamTests { @Test func reportsCarryEverythingSoFarRatherThanTheLatestChunk() async { let reports = Reports() - let stream = AgentCommandOutputStream(interval: .zero) { out, error in + let stream = AgentCommandOutputStream( + interval: .zero, accumulator: AgentOutputAccumulator(path: { "tests/output.txt" }) + ) { out, error in reports.append(out, error) } @@ -135,16 +137,17 @@ struct AgentCommandOutputStreamTests { // The card replaces its result wholesale, so a report carrying only the // newest chunk would make the output flicker rather than grow. - #expect(reports.values.map(\.0) == ["one\n", "one\ntwo\n", "one\ntwo\n"]) - #expect(reports.values.map(\.1) == ["", "", "oops\n"]) - #expect(stream.captured.standardOut == "one\ntwo\n") - #expect(stream.captured.standardError == "oops\n") + #expect(reports.values.map(\.0) == ["one\n", "one\ntwo\n", "one\ntwo\noops\n"]) + #expect(reports.values.map(\.1) == ["", "", ""]) + #expect(await stream.snapshot().output == "one\ntwo\noops\n") } @Test func chunksInsideTheIntervalAreCapturedButNotReported() async { let reports = Reports() - let stream = AgentCommandOutputStream(interval: .seconds(60)) { out, error in + let stream = AgentCommandOutputStream( + interval: .seconds(60), accumulator: AgentOutputAccumulator(path: { "tests/output.txt" }) + ) { out, error in reports.append(out, error) } @@ -153,31 +156,36 @@ struct AgentCommandOutputStreamTests { #expect(reports.values.count == 1, "the second chunk is inside the window") // Throttling a report must never drop the output it was reporting. - #expect(stream.captured.standardOut == "first second") + #expect(await stream.snapshot().output == "first second") } @Test - func captureRetainsLatestOutputAndCountsDiscardedCharactersInsideTheBudget() { - let stream = AgentCommandOutputStream(interval: .zero, limit: 64) { _, _ in } - stream.handler(Data(String(repeating: "a", count: 100).utf8), false) - stream.handler(Data("latest".utf8), false) - let output = stream.captured.standardOut - #expect(output == "[68 chars were truncated]\n" + String(repeating: "a", count: 32) + "latest") - #expect(output.utf8.count <= 64) - #expect(stream.captured.standardError.isEmpty) + func captureRetainsWholeTailLinesAndSavesTheCompleteOutput() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let workspace = AgentScratchWorkspace(base: directory) + let accumulator = AgentOutputAccumulator(workspace: workspace, path: { "chat/outputs/ssh-exec-test.txt" }) + let stream = AgentCommandOutputStream(interval: .zero, accumulator: accumulator) { _, _ in } + let text = String(repeating: "first line\n", count: 7_000) + "latest>" + stream.handler(Data(text.utf8), false) + let snapshot = await stream.snapshot() + #expect(snapshot.output.hasSuffix("latest>")) + #expect(snapshot.output.utf8.count <= 65_536) + #expect(snapshot.isTruncated) + let path = try #require(snapshot.capture?.path) + #expect(try await workspace.data(at: path).data == Data(text.utf8)) } @Test - func truncationPreservesUTF8AndCountsScalarsRatherThanBytes() { - let stream = AgentCommandOutputStream(interval: .zero, limit: 64) { _, _ in } - let scalar = "\u{1F642}" - let data = Data(String(repeating: scalar, count: 30).utf8) + func callbacksCanSplitAUnicodeScalarWithoutCorruptingTheSnapshot() async { + let accumulator = AgentOutputAccumulator(path: { "tests/output.txt" }) + let stream = AgentCommandOutputStream(interval: .zero, accumulator: accumulator) { _, _ in } + let text = String(repeating: "\u{1F642}\n", count: 30) + let data = Data(text.utf8) stream.handler(Data(data.prefix(13)), true) + #expect(!accumulator.preview.contains("\u{FFFD}")) stream.handler(Data(data.dropFirst(13)), true) - let output = stream.captured.standardError - #expect(output == "[21 chars were truncated]\n" + String(repeating: scalar, count: 9)) - #expect(output.utf8.count <= 64) - #expect(stream.captured.standardOut.isEmpty) + #expect(await stream.snapshot().output == text) } /// Locked rather than an actor: the callback under test may not await, so diff --git a/TFMTests/KnowledgeToolRenderingTests.swift b/TFMTests/KnowledgeToolRenderingTests.swift index f3523334..54a1cc5c 100644 --- a/TFMTests/KnowledgeToolRenderingTests.swift +++ b/TFMTests/KnowledgeToolRenderingTests.swift @@ -29,7 +29,6 @@ struct KnowledgeToolRenderingTests { configure() let compact = row.measuredHeight(for: width) #expect(compact > 20) - #expect(compact <= 320, "\(card.descriptor.name): \(compact)") if width == 720, ProcessInfo.processInfo.environment["TERMIND_KNOWLEDGE_SCREENSHOTS"] == "1" { try snapshot( row, width: width, height: compact, name: "mac-\(language.rawValue)-\(card.descriptor.name)") diff --git a/TFMTests/RemoteOutputToolPresentationTests.swift b/TFMTests/RemoteOutputToolPresentationTests.swift index cbe24d79..4a3375f0 100644 --- a/TFMTests/RemoteOutputToolPresentationTests.swift +++ b/TFMTests/RemoteOutputToolPresentationTests.swift @@ -69,7 +69,6 @@ struct RemoteOutputToolPresentationTests { } configure() let compactHeight = row.measuredHeight(for: width) - #expect(compactHeight <= 320) #expect(!descendants(row).compactMap { $0 as? NSTextField }.contains { $0.stringValue == output }) try expectOnlyExpansionControl() try button("AssistantTool.More").performClick(nil) @@ -85,6 +84,23 @@ struct RemoteOutputToolPresentationTests { } } + @Test + func displayFoldingNeverCreatesAMarkerAndActualMarkersAreReusedVerbatim() throws { + var value = card("ssh_exec_output", output: String(repeating: "line\n", count: 100)) + let presenter = try #require(TermindAgentTools.presenter(for: value.descriptor)) + let detail = try #require( + AgentToolPresentation.detail(for: value, presenter: presenter, locale: Locale(identifier: "zh-Hans"))) + #expect(AgentToolDetailPreview(detail).isShortened) + #expect(AgentToolPresentation.truncationNotice(for: value) == nil) + value.result?.isTruncated = true + #expect(AgentToolPresentation.truncationNotice(for: value) == nil) + let marker = AgentTruncateMarker.render( + ranges: [10...20], totalLines: 20, + capture: .init(path: "chat/outputs/ssh-exec-output-test.txt", bytes: 100, lines: 20)) + value.result?.truncateMarker = marker + #expect(AgentToolPresentation.truncationNotice(for: value) == marker) + } + private func card(_ name: String, output: String) -> AgentToolCard { let result: AgentJSONValue = name == "sftp_read" diff --git a/TFMTests/ScrubberWebClientTests.swift b/TFMTests/ScrubberWebClientTests.swift index d4639a56..f4f400ba 100644 --- a/TFMTests/ScrubberWebClientTests.swift +++ b/TFMTests/ScrubberWebClientTests.swift @@ -50,9 +50,13 @@ struct ScrubberWebClientTests { #expect(!original.content.contains("# Readable")) } - @Test - func fetchBoundsEitherRepresentationAt256KiB() async throws { - let oversized = String(repeating: "x", count: 256 * 1_024 + 100) + @Test(arguments: ["markdown", "original"]) + func fetchUsesSharedProjectionAndCapturesTheCompleteRepresentation(_ format: String) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let workspace = AgentScratchWorkspace(base: directory) + let execution = context(workspace: workspace) + let oversized = String(repeating: "x", count: 65_536 + 100) let client = ScrubberWebClient( document: { url in AgentWebDocument( @@ -61,16 +65,20 @@ struct ScrubberWebClientTests { }, search: { _, _ in [] } ) - let result = try await FetchTool(web: client).execute( - invocation("fetch", ["url": .string("https://example.com")]), - context: context() - ) - #expect(result.isTruncated) - #expect(result.content.contains("\"truncated\":true")) + let call = invocation("fetch", ["url": .string("https://example.com"), "format": .string(format)]) + let result = try await FetchTool(web: client).execute(call, context: execution) + #expect(!result.isTruncated) + #expect(result.content.contains(oversized)) + let path = try #require(result.outputCapture?.path) + #expect(path.hasPrefix("\(execution.runID.uuidString.lowercased())/outputs/fetch-")) + #expect(String(decoding: try await workspace.data(at: path).data, as: UTF8.self).contains(oversized)) + let projected = await AgentToolOutputProjection(workspace: workspace).project(result, invocation: call) + #expect(try #require(projected.modelContent).utf8.count <= 65_536) + #expect(projected.truncateMarker?.contains("Full output: \(path)") == true) } @Test - func webSearchReturnsBoundedMarkdownAndEngine() async throws { + func webSearchDoesNotKeepTheOldPerResult32KiBCut() async throws { let oversized = String(repeating: "m", count: 32 * 1_024 + 10) let client = ScrubberWebClient( document: { _ in throw AgentWebError.emptyDocument }, @@ -90,7 +98,9 @@ struct ScrubberWebClientTests { invocation("web_search", ["query": .string("swift concurrency")]), context: context() ) - #expect(result.isTruncated) + #expect(!result.isTruncated) + #expect(result.content.contains(oversized)) + #expect(result.outputCapture == nil) #expect(result.content.contains("\"engine\":\"google\"")) #expect(result.content.contains("\"markdown\":")) #expect(!result.content.contains("")) @@ -117,11 +127,11 @@ struct ScrubberWebClientTests { ) } - private func context() -> AgentToolExecutionContext { + private func context(workspace: AgentScratchWorkspace = .shared()) -> AgentToolExecutionContext { AgentToolExecutionContext( runID: UUID(), userIntent: "read the page", secretBroker: SecretBroker(), userInteraction: UnavailableAgentUserInteraction(), - preflightMetadata: [:], authorize: { _ in .allow } + preflightMetadata: [:], authorize: { _ in .allow }, workspace: workspace ) } } diff --git a/TFMTests/TranscriptListViewTests.swift b/TFMTests/TranscriptListViewTests.swift index b7b9194b..149da578 100644 --- a/TFMTests/TranscriptListViewTests.swift +++ b/TFMTests/TranscriptListViewTests.swift @@ -1074,6 +1074,69 @@ struct TranscriptListViewBridgeTests { #expect(mask.animation(forKey: "scan") != nil) } + @Test(arguments: [360.0, 900.0]) + func compactToolExpansionKeepsBothButtonTitlesLeftAligned(width: Double) throws { + let coordinator = TranscriptListView.Coordinator() + let listView = coordinator.makeListView() + let presentation = TranscriptPresentationState() + let runID = UUID() + let invocation = AgentToolInvocation( + runID: runID, + call: AgentToolCall(id: "read", name: "sftp_read", arguments: .object([:])) + ) + var card = AgentToolCard( + invocation: invocation, + descriptor: AgentToolDescriptor( + name: "sftp_read", summary: "Read a file", inputSchema: .object([:]), + target: .host, approvalPolicy: .approve, + presentation: .init( + symbol: "doc", activity: .semanticArgument(key: "path", fallback: .remoteFile), + presenterID: "builtin.sftp_read") + ) + ) + card.state = .running + card.result = AgentToolResult( + callID: "read", + content: AgentJSONValue.object([ + "path": .string("/tmp/report.txt"), + "content": .string((1...20).map { "Line \($0)" }.joined(separator: "\n")), + "bytes": .number(150), + ]).encodedString + ) + card.result?.truncateMarker = AgentTruncateMarker.render(ranges: [1...20], totalLines: 100) + presentation.disclosures.toggleDisclosure(.tool(card.id)) + let entry = TranscriptEntry( + id: runID, + content: .work( + AgentWorkGroup( + run: AgentRunSummary(id: runID, state: .running, startedAt: .now), + items: [.tools(AgentToolBatch(cards: [card], runID: runID, reasoning: nil))], + orderedAt: .now, anchorMessageID: nil, appearsAfterAnswer: false, + activeReasoningBlockID: nil + )) + ) + listView.frame = CGRect(x: 0, y: 0, width: width, height: 1_200) + update(coordinator, listView, entries: [entry], presentation: presentation) + listView.layoutSubtreeIfNeeded() + + for title in ["Show more", "Show less"] { + let row = try #require(listView.rowView(for: runID)) + let button = try #require(descendant(in: row, identifier: "AssistantTool.More") as? NSButton) + let details = try #require(descendant(in: row, identifier: "AssistantTool.CompactDetails") as? NSStackView) + let notice = try #require(descendant(in: details, identifier: "AssistantTool.TruncationNotice")) + #expect(button.title == title) + #expect(button.alignment == .left) + #expect(abs(details.convert(button.bounds, from: button).minX) <= 1) + #expect( + try #require(details.arrangedSubviews.firstIndex(of: notice)) + < #require(details.arrangedSubviews.firstIndex(of: button))) + button.performClick(nil) + coordinator.refreshDisplayedRow(runID, in: listView) + listView.layoutSubtreeIfNeeded() + } + #expect(!presentation.disclosures.isExpanded(.toolDetail(card.id))) + } + @Test func singleRunningToolTitleStillInstallsTheScanningMask() throws { let coordinator = TranscriptListView.Coordinator() diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 1ff4b1e8..c68a807f 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -3021,7 +3021,7 @@ repositoryURL = "https://github.com/AkinoKaede/AgentKit.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 1.20.0; + minimumVersion = 2.0.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cb34e6a4..7b283754 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/AgentKit.git", "state" : { - "revision" : "a4fd480cdff40ee793b0ca3b239beba4d18d858d", - "version" : "1.20.0" + "revision" : "bd943176ca40bc340f9e850f60d6bfc71098decc", + "version" : "2.0.1" } }, {