feat(agent): unify output truncation and scratch capture
Integrate AgentKit 2.0.1 with shared scratch storage, whole-line tool policies, generated capture paths, and external MCP projection bypass. Update ssh-exec instructions and document the complete tool policy inventory. Keep compact expansion controls left aligned and display the AgentKit truncation marker above them on both platforms. Cover narrow and wide native layouts, both button states, and marker ordering.
This commit is contained in:
1 parent
45e84f28bb
commit
7e78bc00c4
40 files changed
+643
-530
No files matched your search
+1
-1
Submodule AgentKit updated: a4fd480cdf...bd943176ca.
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <conversationID>/outputs/<tool-name>-<UUID>.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
|
||||
|
||||
@@ -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 <conversationID>/outputs/ssh-exec-<UUID>.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 <conversationID>/outputs/ssh-exec-output-<UUID>.txt.
|
||||
AgentKit marks truncation as [Showing lines <start>-<end> of <total>. Full output: <path>].
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = [
|
||||
@@ -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[..<headEnd]) + "\n\(marker)\n" + String(full[tailStart...])
|
||||
}
|
||||
|
||||
private static func boundaryAfterLines(_ count: Int, in text: String) -> 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 {
|
||||
|
||||
@@ -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<Bool>) {
|
||||
preview = AgentToolDetailPreview(detail)
|
||||
public init(
|
||||
detail: AgentToolDetail, isError: Bool, expanded: Binding<Bool>,
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -317,10 +317,6 @@ extension AppStrings {
|
||||
"Output shortened for display"
|
||||
}
|
||||
|
||||
public static var outputTruncated: LocalizedStringResource {
|
||||
"Output truncated"
|
||||
}
|
||||
|
||||
public static var pastedImage: LocalizedStringResource {
|
||||
"Pasted image"
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ public actor AgentBackgroundCommands {
|
||||
let task = Task {
|
||||
let result: Result<SSHExecResult, any Error>
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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, "") }
|
||||
}
|
||||
}
|
||||
@@ -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..<next].utf8.count
|
||||
if bytes + size > maxBytes { break }
|
||||
bytes += size
|
||||
end = next
|
||||
}
|
||||
return (String(text[..<end]), true)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private enum AgentPaneToolCopy {
|
||||
|
||||
@@ -22,15 +22,7 @@ public enum AssistantToolRegistry {
|
||||
let localSearch: ScrubberWebClient? = ai.webSearch.isEnabled && !native ? ScrubberWebClient() : nil
|
||||
var registry = AgentToolRegistry([AnyAgentTool]())
|
||||
if model.abilities.contains(.toolCall) {
|
||||
// One workspace per conversation, not per run: a file staged before the
|
||||
// user interrupted, or before they typed a follow-up, is still there for
|
||||
// the run that answers them. Isolation comes from the scope directory
|
||||
// being keyed by conversation, so one chat can never read what another
|
||||
// pulled off a host.
|
||||
let scratch = AgentScratchWorkspace(
|
||||
conversationID: conversation.id,
|
||||
applicationName: TermindAgentPolicy.scratchDirectoryName
|
||||
)
|
||||
let scratch = AgentScratchWorkspace.shared(applicationName: TermindAgentPolicy.scratchDirectoryName)
|
||||
// One client for both, rather than two default-constructed ones: `fetch`
|
||||
// and `scratch_fetch` read the same web the same way and differ only in
|
||||
// where the page ends up.
|
||||
|
||||
@@ -16,6 +16,8 @@ public nonisolated struct PlanningSSHExecRunTool: AgentToolDefinition {
|
||||
base = SSHExecRunTool(authentication: authentication, pool: pool, background: background, scope: scope)
|
||||
}
|
||||
|
||||
public var truncateStrategy: AgentTruncateStrategy { base.truncateStrategy }
|
||||
|
||||
public var descriptor: AgentToolDescriptor { base.descriptor }
|
||||
|
||||
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
|
||||
|
||||
@@ -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` | `<conversationID>/outputs/ssh-exec-<UUID>.txt` |
|
||||
| `ssh_exec_output` | `<conversationID>/outputs/ssh-exec-output-<UUID>.txt` |
|
||||
| `fetch` | `<conversationID>/outputs/fetch-<UUID>.txt` |
|
||||
| `web_search` | `<conversationID>/outputs/web-search-<UUID>.txt` |
|
||||
| Outbound MCP | `<conversationID>/outputs/<tool-name>-<UUID>.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: <path>]
|
||||
[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.
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <conversationID>/outputs/ssh-exec-<UUID>.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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("<html>"))
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user